Skip to main content

ocserve_http/
lib.rs

1//! ocserve-http: axum router, auth, byte-golden SSE, upstream error envelope.
2#![deny(unsafe_code)]
3
4use axum::extract::State;
5use axum::http::{HeaderValue, StatusCode};
6use axum::response::sse::{Event, Sse};
7use axum::response::{IntoResponse, Response};
8use axum::routing::get;
9use axum::{Json, Router};
10use futures_util::StreamExt;
11use futures_util::stream;
12use serde_json::{Value, json};
13use std::collections::HashMap;
14use std::sync::Arc;
15use std::time::Duration;
16
17/// Freeze (PLAN §3): version reported by `/global/health`.
18pub const FREEZE_VERSION: &str = "1.18.31";
19
20/// Pre-assembled payloads for config-derived routes (built by ocserve's
21/// `Runtime::load()`; kept crate-local so http doesn't depend on cli).
22/// JSON deep merge: objects merge recursively, everything else (arrays,
23/// scalars) replaces. Used by overlay layering + PATCH /config (W4).
24pub fn deep_merge(base: &mut serde_json::Value, patch: serde_json::Value) {
25    match (base, patch) {
26        (serde_json::Value::Object(b), serde_json::Value::Object(p)) => {
27            for (k, v) in p {
28                match b.get_mut(&k) {
29                    Some(bv) => deep_merge(bv, v),
30                    None => {
31                        b.insert(k, v);
32                    }
33                }
34            }
35        }
36        (b, p) => *b = p,
37    }
38}
39
40/// W4 config-write target: default = the SHARED opencode.json (drop-in
41/// contract — both servers read it); `OCSERVE_CONFIG_WRITE=overlay` switches
42/// to a ocserve-owned file that layers over the base at load (never touches
43/// the other server's config).
44pub fn config_write_path() -> std::path::PathBuf {
45    let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
46    if std::env::var("OCSERVE_CONFIG_WRITE")
47        .map(|v| v == "overlay")
48        .unwrap_or(false)
49    {
50        std::path::PathBuf::from(home).join(".config/ocserve/config.json")
51    } else {
52        std::path::PathBuf::from(home).join(".config/opencode/opencode.json")
53    }
54}
55
56/// Boot-injected config reloader (W4): Runtime::load lives in ocserve —
57/// the closure avoids a crate cycle; None in tests. Returns derived payloads
58/// AND a rebuilt LLM registry (H2 — auth/provider/default-model edits apply
59/// without restart). Callers go through `watch::reconcile`, never the raw fn.
60pub type ConfigReloader =
61    std::sync::Arc<dyn Fn() -> anyhow::Result<(Payloads, LlmRegistry)> + Send + Sync>;
62
63#[derive(Default)]
64pub struct Payloads {
65    pub config: serde_json::Value,
66    pub agent: Vec<serde_json::Value>,
67    pub api_agent: Vec<serde_json::Value>,
68    pub command: Vec<serde_json::Value>,
69    pub config_providers: serde_json::Value,
70    pub provider: serde_json::Value,
71    pub console: serde_json::Value,
72    pub capabilities: serde_json::Value,
73    /// shared-config `compaction` section (M6 trigger/knobs).
74    pub compaction: serde_json::Value,
75    /// Parsed models catalog (`models.json`, ~5.4 MB), swapped with the other
76    /// payloads on reload. Shared so v2 metadata routes never re-read +
77    /// re-parse the file per request — the pre-fix `read_catalog_value` did
78    /// exactly that (100 ms CPU + ~25 MB retention per call; conc-6 → 8 s).
79    pub catalog: std::sync::Arc<serde_json::Value>,
80}
81
82/// PERF-10X F1: run a blocking store call on the runtime's blocking pool.
83/// Store fns are sync by design (STORAGE: never hold a connection across
84/// `.await`) — calling them inline parks a tokio worker for the query's
85/// duration. Baseline measured exactly that convoy: one 6s search held a
86/// worker and every other route queued behind it (config p95 998ms for a
87/// payload read; starvation signature: tail explodes while CPU ≪100%).
88async fn run_blocking<T, F>(db: &std::path::Path, op: F) -> anyhow::Result<T>
89where
90    F: FnOnce(&std::path::Path) -> anyhow::Result<T> + Send + 'static,
91    T: Send + 'static,
92{
93    let db = db.to_path_buf();
94    tokio::task::spawn_blocking(move || op(&db))
95        .await
96        .map_err(|e| anyhow::anyhow!("blocking task join: {e}"))?
97}
98
99pub mod compress;
100pub mod doc;
101pub mod embedded_ui;
102pub mod pty;
103pub mod tui;
104pub mod ui;
105pub mod v2;
106pub mod vcs;
107
108/// Upstream error envelope: {"name":"NotFoundError","data":{"message":"..."}} (captured live).
109pub struct ApiError {
110    pub status: StatusCode,
111    pub name: &'static str,
112    pub message: String,
113}
114
115impl ApiError {
116    pub fn not_found(msg: impl Into<String>) -> Self {
117        Self {
118            status: StatusCode::NOT_FOUND,
119            name: "NotFoundError",
120            message: msg.into(),
121        }
122    }
123}
124
125impl IntoResponse for ApiError {
126    fn into_response(self) -> Response {
127        let body = json!({"name": self.name, "data": {"message": self.message}});
128        (self.status, Json(body)).into_response()
129    }
130}
131
132/// Handler error: normal envelopes + Effect HttpApi tagged decode errors
133/// (freeze parity: `?before=` → body exactly {"_tag":"BadRequest"} §1090).
134pub mod sync;
135pub mod watch;
136
137pub enum HttpError {
138    Api(ApiError),
139    Tagged {
140        status: StatusCode,
141        tag: &'static str,
142    },
143    /// Effect query-decode failure envelope (freeze probes: `?limit=abc` →
144    /// {"name":"BadRequest","data":{"message":...,"kind":"Query"}}).
145    Query {
146        message: String,
147    },
148    /// Effect payload-decode failure envelope (freeze probes: missing body key
149    /// → {"name":"BadRequest","data":{"message":"Missing key\n  at [...]",
150    /// "kind":"Payload"}}).
151    Payload {
152        message: String,
153    },
154    /// Effect path-param decode failure envelope (freeze: bad `que_`/`per_`
155    /// prefix → {"name":"BadRequest","data":{"message":"Expected a string
156    /// starting with \"que\", got \"x\"\n  at [\"requestID\"]","kind":"Params"}}).
157    Params {
158        message: String,
159    },
160    /// Effect TaggedErrorClass envelope with schema fields at top level
161    /// (errors.ts:143 McpServerNotFoundError → {"_tag":..., name, message}).
162    TaggedData {
163        status: StatusCode,
164        tag: &'static str,
165        fields: serde_json::Value,
166    },
167}
168
169impl From<ApiError> for HttpError {
170    fn from(e: ApiError) -> Self {
171        HttpError::Api(e)
172    }
173}
174
175impl IntoResponse for HttpError {
176    fn into_response(self) -> Response {
177        match self {
178            HttpError::Api(e) => e.into_response(),
179            HttpError::Tagged { status, tag } => {
180                let body = format!("{{\"_tag\":\"{tag}\"}}");
181                (
182                    status,
183                    [(axum::http::header::CONTENT_TYPE, "application/json")],
184                    body,
185                )
186                    .into_response()
187            }
188            HttpError::TaggedData {
189                status,
190                tag,
191                fields,
192            } => {
193                let mut obj = serde_json::Map::new();
194                obj.insert("_tag".into(), serde_json::Value::String(tag.into()));
195                if let serde_json::Value::Object(f) = fields {
196                    obj.extend(f);
197                }
198                (
199                    status,
200                    [(axum::http::header::CONTENT_TYPE, "application/json")],
201                    serde_json::Value::Object(obj).to_string(),
202                )
203                    .into_response()
204            }
205            HttpError::Query { message } => {
206                let body = serde_json::json!({
207                    "name": "BadRequest",
208                    "data": {"message": message, "kind": "Query"}
209                })
210                .to_string();
211                (
212                    StatusCode::BAD_REQUEST,
213                    [(axum::http::header::CONTENT_TYPE, "application/json")],
214                    body,
215                )
216                    .into_response()
217            }
218            HttpError::Payload { message } => {
219                let body = serde_json::json!({
220                    "name": "BadRequest",
221                    "data": {"message": message, "kind": "Payload"}
222                })
223                .to_string();
224                (
225                    StatusCode::BAD_REQUEST,
226                    [(axum::http::header::CONTENT_TYPE, "application/json")],
227                    body,
228                )
229                    .into_response()
230            }
231            HttpError::Params { message } => {
232                let body = serde_json::json!({
233                    "name": "BadRequest",
234                    "data": {"message": message, "kind": "Params"}
235                })
236                .to_string();
237                (
238                    StatusCode::BAD_REQUEST,
239                    [(axum::http::header::CONTENT_TYPE, "application/json")],
240                    body,
241                )
242                    .into_response()
243            }
244        }
245    }
246}
247
248/// session → (generation, background prompt task) for POST /abort.
249pub type PromptTask = (u64, tokio::task::JoinHandle<()>);
250
251/// App state shared by handlers.
252pub struct AppState {
253    /// Config-derived route payloads (W4: swappable after PATCH /config —
254    /// upstream marks the instance for disposal and serves fresh config).
255    pub payloads: parking_lot::RwLock<Payloads>,
256    /// PERF-10X F5: pre-serialized bytes for the hot Json-clone handlers
257    /// (config/agent/command/config_providers/provider/console/capabilities).
258    /// Built at construction + every reload, so a request is a Bytes clone
259    /// (refcount) + zero-copy Body — no per-request Value clone/serialize.
260    /// Kill-switch: OCSERVE_WIRE_CACHE=0 (wire_off) falls back to the Value
261    /// path; both paths serve serde-identical bytes (unit-tested).
262    pub wire: parking_lot::RwLock<HashMap<&'static str, WireEntry>>,
263    pub wire_off: std::sync::atomic::AtomicBool,
264    /// Boot-injected config reloader (Runtime lives in ocserve; the
265    /// closure avoids a crate cycle). None in tests → PATCH still writes
266    /// the file, swap skipped (logged).
267    pub reloader: parking_lot::RwLock<Option<ConfigReloader>>,
268    /// Runtime paths for GET /path (env-derived at boot; never stored in repo).
269    pub paths: serde_json::Value,
270    /// Auth mode: None = off (freeze default), Some(("user","pass")) = basic.
271    pub auth: Option<(String, String)>,
272    /// Request counter for /metrics (bounded, monotonic).
273    pub requests: std::sync::atomic::AtomicU64,
274    // ---- M2 prompt runtime (wired by CLI) ----
275    /// Bounded broadcast of encoded global-event frames.
276    pub bus: ocserve_core::EventBus,
277    pub db: std::path::PathBuf,
278    pub blobs: std::sync::Arc<ocserve_store::BlobStore>,
279    pub writer: std::sync::Arc<ocserve_store::Writer>,
280    /// Provider registry + default model + agent systems (from Runtime).
281    /// RwLock since H2: rebuilds on config hot-reload (watch::reconcile);
282    /// in-flight prompts keep the values they resolved at build time.
283    pub llm: parking_lot::RwLock<LlmRegistry>,
284    /// Config hot-reload watch state (H1): polled paths + observed tuples.
285    pub watch: parking_lot::RwLock<watch::WatchState>,
286    /// Serializes reconcile across PATCH/auth/watcher callers (tokio Mutex —
287    /// held across reload + MCP awaits; never a parking_lot guard over await).
288    pub reconcile_lock: tokio::sync::Mutex<()>,
289    /// K-AUTONOMY knobs — read from env ONCE at construction (changing
290    /// OCSERVE_PROMPT_MAX_ROUNDS / OCSERVE_PROMPT_MAX_COST_USD = restart).
291    pub max_rounds: usize,
292    pub cost_ceiling: f64,
293    /// Permission rendezvous shared by runner + reply routes.
294    pub gate: std::sync::Arc<ocserve_core::PermissionGate>,
295    /// MCP hub probed at serve boot (OnceLock: tests run without probing).
296    pub mcp: std::sync::OnceLock<std::sync::Arc<ocserve_mcp::McpHub>>,
297    /// Plugin sidecar (M4b): async mutex — trigger() takes &mut across await.
298    pub plugins: std::sync::OnceLock<std::sync::Arc<tokio::sync::Mutex<ocserve_plugin::Sidecar>>>,
299    /// Per-session prompt serialization (queue semantics). Bounded: entries
300    /// exist only for in-flight prompts + transient races, removed post-run
301    /// when uncontended (AGENTS §2.3 — active-set bound, no eviction).
302    pub prompt_locks: std::sync::Arc<
303        parking_lot::Mutex<
304            std::collections::HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>,
305        >,
306    >,
307    /// Background prompt tasks per session: (generation, JoinHandle) —
308    /// POST /abort cancels the current generation (v1 session.abort).
309    /// Bound: one entry per in-flight async prompt, removed on completion.
310    pub prompt_tasks:
311        std::sync::Arc<parking_lot::Mutex<std::collections::HashMap<String, PromptTask>>>,
312    /// Monotonic generation source for prompt_tasks.
313    pub prompt_gen: std::sync::Arc<std::sync::atomic::AtomicU64>,
314    /// Question rendezvous (v1 Question service): pending asks + reply/reject.
315    pub question_gate: std::sync::Arc<ocserve_core::question::QuestionGate>,
316    /// /tui/control rendezvous queues (v1 shared/tui-control.ts)
317    pub tui: crate::tui::TuiControl,
318    /// Web-UI reverse proxy (Phase 7; upstream serveUIEffect parity)
319    pub ui: crate::ui::UiProxy,
320    /// PTY sessions (/pty/* — web-UI terminal)
321    pub pty: std::sync::Arc<ocserve_pty::PtyManager>,
322    /// User CORS allowlist (config `server.cors` + `--cors`), read once at boot
323    pub cors_extra: Vec<String>,
324}
325
326/// LLM endpoint resolution for the prompt runner (assembled by Runtime).
327#[derive(Default)]
328pub struct LlmRegistry {
329    /// providerID → (base_url, api_key)
330    pub endpoints: std::collections::HashMap<String, (String, String)>,
331    /// (providerID, modelID) → (input, output, cache_read) USD/MTok
332    pub pricing: std::collections::HashMap<(String, String), (f64, f64, f64)>,
333    /// (providerID, modelID) → `limit` block (context/input) — M6 trigger
334    pub limits: std::collections::HashMap<(String, String), serde_json::Value>,
335    /// default (providerID, modelID) from opencode state
336    pub default_model: (String, String),
337    /// agent name → system prompt (only agents with explicit prompts)
338    pub systems: std::collections::HashMap<String, String>,
339    /// config default_agent (upstream session rows record it at prompt time)
340    pub default_agent: String,
341}
342
343/// Real runtime pieces passed at construction (no interior mutability, no unsafe).
344pub struct Wires {
345    pub db: std::path::PathBuf,
346    pub blobs: std::sync::Arc<ocserve_store::BlobStore>,
347    pub writer: std::sync::Arc<ocserve_store::Writer>,
348    pub llm: LlmRegistry,
349}
350
351/// One wire-cache entry: identity bytes, precomputed compressed variants,
352/// and a strong ETag over the identity body (WEBUI-PLAN W1/W2).
353///
354/// Compressed variants are built once per write-epoch (reload), never on the
355/// request path — the provider payload is 6.2 MB and brotli-q5 costs ~0.18 s.
356#[derive(Clone)]
357pub struct WireEntry {
358    pub identity: bytes::Bytes,
359    pub br: bytes::Bytes,
360    pub gzip: bytes::Bytes,
361    pub etag: String,
362}
363
364/// Serialize the hot payload fields once (reload-time). off => empty map
365/// (handlers fall back to the Value path). `location` is the v2 envelope's
366/// location object (built from `paths`, which is static per process).
367pub(crate) fn rebuild_wire(
368    p: &Payloads,
369    off: bool,
370    location: &Value,
371) -> HashMap<&'static str, WireEntry> {
372    let mut m = HashMap::new();
373    if off {
374        return m;
375    }
376    // serde_json::to_vec is exactly what axum's Json uses — byte-identical.
377    fn one<T: serde::Serialize>(m: &mut HashMap<&'static str, WireEntry>, k: &'static str, v: &T) {
378        if let Ok(b) = serde_json::to_vec(v) {
379            let identity = bytes::Bytes::from(b);
380            let etag = crate::compress::etag_for(&identity);
381            let br = bytes::Bytes::from(crate::compress::compress(
382                &identity,
383                crate::compress::Encoding::Brotli,
384            ));
385            let gzip = bytes::Bytes::from(crate::compress::compress(
386                &identity,
387                crate::compress::Encoding::Gzip,
388            ));
389            m.insert(
390                k,
391                WireEntry {
392                    identity,
393                    br,
394                    gzip,
395                    etag,
396                },
397            );
398        }
399    }
400    // /global/config == /config minus instance-scoped keys (freeze probe).
401    {
402        let mut gc = p.config.clone();
403        if let Some(obj) = gc.as_object_mut() {
404            for k in ["agent", "command", "mode", "username"] {
405                obj.remove(k);
406            }
407        }
408        one(&mut m, "global_config", &gc);
409    }
410    one(&mut m, "config", &p.config);
411    one(&mut m, "agent", &p.agent);
412    one(&mut m, "command", &p.command);
413    one(&mut m, "config_providers", &p.config_providers);
414    one(&mut m, "provider", &p.provider);
415    one(&mut m, "console", &p.console);
416    one(&mut m, "capabilities", &p.capabilities);
417    // v2 metadata group: projection is pure over Payloads (the pre-fix
418    // handlers re-read + re-parsed the 5.4 MB catalog and deep-cloned the
419    // provider tree on EVERY request).
420    one(&mut m, "api_model", &crate::v2::models_body(p, location));
421    one(
422        &mut m,
423        "api_provider",
424        &crate::v2::providers_body(p, location),
425    );
426    one(
427        &mut m,
428        "api_integration",
429        &crate::v2::integrations_body(p, location),
430    );
431    // v2 agent/command lists (perf dive 2026-10-11): per-request projection +
432    // serde on every UI refresh; pure over Payloads, so cache like the rest.
433    one(&mut m, "api_agent", &api_agent_body(p, location));
434    one(&mut m, "api_command", &api_command_body(p, location));
435    m
436}
437
438/// Serve cached bytes as a JSON response with negotiation + validators; None
439/// = fall back to the Value clone path (wire cache off / unknown key).
440/// Identity clients (no Accept-Encoding, no If-None-Match) receive the exact
441/// identity bytes and content-type as before (WEBUI-PLAN target 3).
442fn wire_json_h(
443    st: &AppState,
444    key: &'static str,
445    accept_encoding: Option<&str>,
446    if_none_match: Option<&str>,
447) -> Option<axum::response::Response> {
448    if st.wire_off.load(std::sync::atomic::Ordering::Relaxed) {
449        return None;
450    }
451    let e = st.wire.read().get(key).cloned()?;
452    let encoding = crate::compress::negotiate(accept_encoding);
453    // 304 short-circuit (RFC 9111): before any body work.
454    if crate::compress::if_none_match_matches(if_none_match, &e.etag) {
455        return Some(
456            axum::response::Response::builder()
457                .status(axum::http::StatusCode::NOT_MODIFIED)
458                .header(axum::http::header::ETAG, &e.etag)
459                .header(axum::http::header::VARY, "Accept-Encoding")
460                .body(axum::body::Body::empty())
461                .expect("static 304"),
462        );
463    }
464    let mut b = axum::response::Response::builder()
465        .header(axum::http::header::CONTENT_TYPE, "application/json")
466        .header(axum::http::header::ETAG, &e.etag)
467        .header(axum::http::header::VARY, "Accept-Encoding");
468    let body = match encoding {
469        Some(crate::compress::Encoding::Brotli) => {
470            b = b.header(axum::http::header::CONTENT_ENCODING, "br");
471            e.br.clone()
472        }
473        Some(crate::compress::Encoding::Gzip) => {
474            b = b.header(axum::http::header::CONTENT_ENCODING, "gzip");
475            e.gzip.clone()
476        }
477        None => e.identity.clone(),
478    };
479    Some(
480        b.body(axum::body::Body::from(body))
481            .expect("static response"),
482    )
483}
484
485/// Back-compat shim (tests + callers with no header context): identity only.
486#[cfg(test)]
487fn wire_json(st: &AppState, key: &'static str) -> Option<axum::response::Response> {
488    wire_json_h(st, key, None, None)
489}
490
491impl AppState {
492    pub fn new() -> Arc<Self> {
493        Self::with_auth(None)
494    }
495
496    pub fn with_auth(auth: Option<(String, String)>) -> Arc<Self> {
497        Self::with_payloads(auth, Payloads::default())
498    }
499
500    /// Build from pre-assembled route payloads with a temp-backed store
501    /// (tests / simple construction — real pieces, no placeholders).
502    pub fn with_payloads(auth: Option<(String, String)>, p: Payloads) -> Arc<Self> {
503        // pid + nanos + counter: parallel tests calling this on the same clock
504        // tick must not share a temp data dir (writer/db contention → flaky).
505        static NEW_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
506        let dir = std::env::temp_dir().join(format!(
507            "ocserve-http-{}-{}-{}",
508            std::process::id(),
509            std::time::SystemTime::now()
510                .duration_since(std::time::UNIX_EPOCH)
511                .map(|d| d.as_nanos())
512                .unwrap_or(0),
513            NEW_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
514        ));
515        std::fs::create_dir_all(&dir).expect("temp data dir");
516        let db = ocserve_store::writer::db_path(&dir);
517        let writer =
518            std::sync::Arc::new(ocserve_store::Writer::spawn(db.clone()).expect("temp writer"));
519        let blobs = std::sync::Arc::new(
520            ocserve_store::BlobStore::new(dir.join("blobs")).expect("temp blobs"),
521        );
522        Self::with_wiring(
523            auth,
524            p,
525            Wires {
526                db,
527                blobs,
528                writer,
529                llm: LlmRegistry::default(),
530            },
531        )
532    }
533
534    /// One-shot construction with real runtime wiring (CLI path).
535    pub fn with_wiring(auth: Option<(String, String)>, p: Payloads, w: Wires) -> Arc<Self> {
536        let home = std::env::var("HOME").unwrap_or_default();
537        let worktree = std::env::current_dir()
538            .map(|p| p.display().to_string())
539            .unwrap_or_else(|_| "/".into());
540        let wire_off = std::env::var("OCSERVE_WIRE_CACHE")
541            .map(|v| v == "0")
542            .unwrap_or(false);
543        // v2 envelope location (static per process — same value every request).
544        let location = json!({
545            "directory": worktree,
546            "project": {"id": "global", "directory": "/"},
547        });
548        let wire_map = rebuild_wire(&p, wire_off, &location);
549        let bus = ocserve_core::EventBus::new();
550        let pty = {
551            let mgr = ocserve_pty::PtyManager::new(worktree.clone());
552            let bus_for_pty = bus.clone();
553            let dir_for_pty = worktree.clone();
554            mgr.set_event_sink(std::sync::Arc::new(move |event_type, properties| {
555                bus_for_pty.publish(ocserve_core::event::frame(
556                    &dir_for_pty,
557                    event_type,
558                    properties,
559                ));
560            }));
561            mgr
562        };
563        Arc::new(Self {
564            payloads: parking_lot::RwLock::new(p),
565            wire: parking_lot::RwLock::new(wire_map),
566            wire_off: std::sync::atomic::AtomicBool::new(wire_off),
567            reloader: parking_lot::RwLock::new(None),
568            // upstream /path shape (keys golden: home/state/config/worktree/directory)
569            paths: json!({
570                "home": home,
571                "state": format!("{home}/.local/state/opencode"),
572                "config": format!("{home}/.config/opencode"),
573                "worktree": worktree,
574                "directory": worktree,
575            }),
576            auth,
577            requests: std::sync::atomic::AtomicU64::new(0),
578            bus,
579            gate: ocserve_core::PermissionGate::new(),
580            mcp: std::sync::OnceLock::new(),
581            plugins: std::sync::OnceLock::new(),
582            prompt_locks: std::sync::Arc::new(parking_lot::Mutex::new(
583                std::collections::HashMap::new(),
584            )),
585            prompt_tasks: std::sync::Arc::new(parking_lot::Mutex::new(
586                std::collections::HashMap::new(),
587            )),
588            prompt_gen: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1)),
589            question_gate: ocserve_core::question::QuestionGate::new(),
590            tui: crate::tui::TuiControl::default(),
591            ui: crate::ui::UiProxy::from_env(),
592            pty,
593            cors_extra: read_cors_env(),
594            db: w.db,
595            blobs: w.blobs,
596            writer: w.writer,
597            llm: parking_lot::RwLock::new(w.llm),
598            watch: parking_lot::RwLock::new(watch::WatchState::default()),
599            reconcile_lock: tokio::sync::Mutex::new(()),
600            max_rounds: std::env::var("OCSERVE_PROMPT_MAX_ROUNDS")
601                .ok()
602                .and_then(|v| v.parse().ok())
603                .unwrap_or(0),
604            cost_ceiling: std::env::var("OCSERVE_PROMPT_MAX_COST_USD")
605                .ok()
606                .and_then(|v| v.parse().ok())
607                .unwrap_or(0.0),
608        })
609    }
610}
611
612async fn health() -> impl IntoResponse {
613    Json(json!({"healthy": true, "version": FREEZE_VERSION}))
614}
615
616/// `GET /doc` — serve the vendored OpenAPI contract (freeze parity).
617async fn get_doc() -> impl IntoResponse {
618    (
619        [(axum::http::header::CONTENT_TYPE, "application/json")],
620        crate::doc::SPEC_JSON,
621    )
622}
623
624async fn get_config(
625    State(st): State<Arc<AppState>>,
626    headers: axum::http::HeaderMap,
627) -> axum::response::Response {
628    let ae = headers
629        .get(axum::http::header::ACCEPT_ENCODING)
630        .and_then(|v| v.to_str().ok());
631    let inm = headers
632        .get(axum::http::header::IF_NONE_MATCH)
633        .and_then(|v| v.to_str().ok());
634    wire_json_h(&st, "config", ae, inm).unwrap_or_else(|| {
635        let v = st.payloads.read().config.clone();
636        let body = serde_json::to_vec(&v).unwrap_or_default();
637        let etag = crate::compress::etag_for(&body);
638        crate::compress::encode_response(
639            axum::http::StatusCode::OK,
640            "application/json",
641            bytes::Bytes::from(body),
642            Some(&etag),
643            ae,
644            inm,
645            &[],
646        )
647    })
648}
649
650async fn get_agent(
651    State(st): State<Arc<AppState>>,
652    headers: axum::http::HeaderMap,
653) -> axum::response::Response {
654    let ae = headers
655        .get(axum::http::header::ACCEPT_ENCODING)
656        .and_then(|v| v.to_str().ok());
657    let inm = headers
658        .get(axum::http::header::IF_NONE_MATCH)
659        .and_then(|v| v.to_str().ok());
660    wire_json_h(&st, "agent", ae, inm).unwrap_or_else(|| {
661        let v = st.payloads.read().agent.clone();
662        let body = serde_json::to_vec(&v).unwrap_or_default();
663        let etag = crate::compress::etag_for(&body);
664        crate::compress::encode_response(
665            axum::http::StatusCode::OK,
666            "application/json",
667            bytes::Bytes::from(body),
668            Some(&etag),
669            ae,
670            inm,
671            &[],
672        )
673    })
674}
675
676async fn get_command(
677    State(st): State<Arc<AppState>>,
678    headers: axum::http::HeaderMap,
679) -> axum::response::Response {
680    let ae = headers
681        .get(axum::http::header::ACCEPT_ENCODING)
682        .and_then(|v| v.to_str().ok());
683    let inm = headers
684        .get(axum::http::header::IF_NONE_MATCH)
685        .and_then(|v| v.to_str().ok());
686    wire_json_h(&st, "command", ae, inm).unwrap_or_else(|| {
687        let v = st.payloads.read().command.clone();
688        let body = serde_json::to_vec(&v).unwrap_or_default();
689        let etag = crate::compress::etag_for(&body);
690        crate::compress::encode_response(
691            axum::http::StatusCode::OK,
692            "application/json",
693            bytes::Bytes::from(body),
694            Some(&etag),
695            ae,
696            inm,
697            &[],
698        )
699    })
700}
701
702async fn get_config_providers(
703    State(st): State<Arc<AppState>>,
704    headers: axum::http::HeaderMap,
705) -> axum::response::Response {
706    let ae = headers
707        .get(axum::http::header::ACCEPT_ENCODING)
708        .and_then(|v| v.to_str().ok());
709    let inm = headers
710        .get(axum::http::header::IF_NONE_MATCH)
711        .and_then(|v| v.to_str().ok());
712    wire_json_h(&st, "config_providers", ae, inm).unwrap_or_else(|| {
713        let v = st.payloads.read().config_providers.clone();
714        let body = serde_json::to_vec(&v).unwrap_or_default();
715        let etag = crate::compress::etag_for(&body);
716        crate::compress::encode_response(
717            axum::http::StatusCode::OK,
718            "application/json",
719            bytes::Bytes::from(body),
720            Some(&etag),
721            ae,
722            inm,
723            &[],
724        )
725    })
726}
727
728async fn get_provider(
729    State(st): State<Arc<AppState>>,
730    headers: axum::http::HeaderMap,
731) -> axum::response::Response {
732    let ae = headers
733        .get(axum::http::header::ACCEPT_ENCODING)
734        .and_then(|v| v.to_str().ok());
735    let inm = headers
736        .get(axum::http::header::IF_NONE_MATCH)
737        .and_then(|v| v.to_str().ok());
738    wire_json_h(&st, "provider", ae, inm).unwrap_or_else(|| {
739        let v = st.payloads.read().provider.clone();
740        let body = serde_json::to_vec(&v).unwrap_or_default();
741        let etag = crate::compress::etag_for(&body);
742        crate::compress::encode_response(
743            axum::http::StatusCode::OK,
744            "application/json",
745            bytes::Bytes::from(body),
746            Some(&etag),
747            ae,
748            inm,
749            &[],
750        )
751    })
752}
753
754async fn get_console(
755    State(st): State<Arc<AppState>>,
756    headers: axum::http::HeaderMap,
757) -> axum::response::Response {
758    let ae = headers
759        .get(axum::http::header::ACCEPT_ENCODING)
760        .and_then(|v| v.to_str().ok());
761    let inm = headers
762        .get(axum::http::header::IF_NONE_MATCH)
763        .and_then(|v| v.to_str().ok());
764    wire_json_h(&st, "console", ae, inm).unwrap_or_else(|| {
765        let v = st.payloads.read().console.clone();
766        let body = serde_json::to_vec(&v).unwrap_or_default();
767        let etag = crate::compress::etag_for(&body);
768        crate::compress::encode_response(
769            axum::http::StatusCode::OK,
770            "application/json",
771            bytes::Bytes::from(body),
772            Some(&etag),
773            ae,
774            inm,
775            &[],
776        )
777    })
778}
779
780async fn get_capabilities(
781    State(st): State<Arc<AppState>>,
782    headers: axum::http::HeaderMap,
783) -> axum::response::Response {
784    let ae = headers
785        .get(axum::http::header::ACCEPT_ENCODING)
786        .and_then(|v| v.to_str().ok());
787    let inm = headers
788        .get(axum::http::header::IF_NONE_MATCH)
789        .and_then(|v| v.to_str().ok());
790    wire_json_h(&st, "capabilities", ae, inm).unwrap_or_else(|| {
791        let v = st.payloads.read().capabilities.clone();
792        let body = serde_json::to_vec(&v).unwrap_or_default();
793        let etag = crate::compress::etag_for(&body);
794        crate::compress::encode_response(
795            axum::http::StatusCode::OK,
796            "application/json",
797            bytes::Bytes::from(body),
798            Some(&etag),
799            ae,
800            inm,
801            &[],
802        )
803    })
804}
805
806/// v2-style location envelope shared by /api/* routes (captured live).
807pub(crate) fn api_location(st: &AppState) -> Value {
808    let dir = st.paths["directory"].as_str().unwrap_or("/");
809    json!({
810        "directory": dir,
811        "project": {"id": "global", "directory": "/"},
812    })
813}
814
815/// GET /api/agent — v2-shaped agent list (permission triple renamed to
816/// {action, resource, effect}; natives-first order; no plugin agents).
817async fn get_api_agent(
818    State(st): State<Arc<AppState>>,
819    headers: axum::http::HeaderMap,
820) -> axum::response::Response {
821    let ae = headers
822        .get(axum::http::header::ACCEPT_ENCODING)
823        .and_then(|v| v.to_str().ok());
824    let inm = headers
825        .get(axum::http::header::IF_NONE_MATCH)
826        .and_then(|v| v.to_str().ok());
827    crate::wire_json_h(&st, "api_agent", ae, inm).unwrap_or_else(|| {
828        let payloads = st.payloads.read();
829        Json(api_agent_body(&payloads, &api_location(&st))).into_response()
830    })
831}
832
833/// Pure projection for /api/agent (cache + fallback share this).
834pub(crate) fn api_agent_body(payloads: &Payloads, location: &Value) -> Value {
835    json!({"location": location, "data": api_agent_data(payloads)})
836}
837
838fn api_agent_data(payloads: &Payloads) -> Vec<Value> {
839    let data: Vec<Value> = payloads
840        .api_agent
841        .iter()
842        .map(|a| {
843            let perms: Vec<Value> = a["permission"]
844                .as_array()
845                .map(|rules| {
846                    rules
847                        .iter()
848                        .map(|r| {
849                            json!({
850                                "action": r["permission"],
851                                "resource": r["pattern"],
852                                "effect": r["action"],
853                            })
854                        })
855                        .collect()
856                })
857                .unwrap_or_default();
858            let mut out = json!({
859                "id": a["name"],
860                "hidden": a.get("hidden").and_then(|v| v.as_bool()).unwrap_or(false),
861                "mode": a["mode"],
862                "permissions": perms,
863                "request": {"headers": {}, "body": {}},
864            });
865            if let Some(d) = a.get("description") {
866                out["description"] = d.clone();
867            }
868            // system: explicit prompt, else the captured build default
869            if let Some(p) = a.get("prompt") {
870                out["system"] = p.clone();
871            } else if a["name"] == "build" {
872                out["system"] = json!(BUILD_SYSTEM_BLURB);
873            }
874            out
875        })
876        .collect();
877    data
878}
879
880/// Captured from live /api/agent (build has no prompt; upstream serves this blurb).
881const BUILD_SYSTEM_BLURB: &str = "You are an AI coding agent. Help the user accomplish software \
882engineering tasks by inspecting the workspace, making targeted changes, and using tools according \
883to the configured permissions.";
884
885/// GET /api/command — {location, data:[{description,name,template}]}.
886async fn get_api_command(
887    State(st): State<Arc<AppState>>,
888    headers: axum::http::HeaderMap,
889) -> axum::response::Response {
890    let ae = headers
891        .get(axum::http::header::ACCEPT_ENCODING)
892        .and_then(|v| v.to_str().ok());
893    let inm = headers
894        .get(axum::http::header::IF_NONE_MATCH)
895        .and_then(|v| v.to_str().ok());
896    crate::wire_json_h(&st, "api_command", ae, inm).unwrap_or_else(|| {
897        let payloads = st.payloads.read();
898        Json(api_command_body(&payloads, &api_location(&st))).into_response()
899    })
900}
901
902/// Pure projection for /api/command (cache + fallback share this).
903pub(crate) fn api_command_body(payloads: &Payloads, location: &Value) -> Value {
904    let data: Vec<Value> = payloads
905        .command
906        .iter()
907        .map(|c| {
908            json!({
909                "name": c["name"],
910                "description": c.get("description").and_then(|v| v.as_str()).unwrap_or(""),
911                "template": c.get("template").and_then(|v| v.as_str()).unwrap_or(""),
912            })
913        })
914        .collect();
915    json!({"location": location, "data": data})
916}
917
918/// Empty-shape routes captured live: {}, [].
919/// GET /experimental/tool?provider=&model= — v1 ToolList (groups/
920/// experimental.ts:50-61; handlers/experimental.ts:94-105): [{id,
921/// description, parameters}] = builtin + MCP tools. Effect validates the
922/// query (missing provider/model → BadRequest) — mirrored here. Denied
923/// tools excluded, matching what the model actually sees (W5).
924async fn get_experimental_tool(
925    State(st): State<Arc<AppState>>,
926    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
927) -> Result<Json<Value>, ApiError> {
928    let has = |k: &str| q.get(k).map(|s| !s.is_empty()).unwrap_or(false);
929    if !has("provider") || !has("model") {
930        return Err(ApiError {
931            status: StatusCode::BAD_REQUEST,
932            name: "BadRequest",
933            message: "Invalid query: provider, model required".into(),
934        });
935    }
936    let mut schemas = ocserve_tools::schemas();
937    if let Some(hub) = st.mcp.get() {
938        let mcp_tools =
939            tokio::time::timeout(std::time::Duration::from_secs(10), hub.tool_schemas())
940                .await
941                .unwrap_or_else(|_| {
942                    tracing::warn!("mcp tool schema fetch timed out (/experimental/tool)");
943                    Vec::new()
944                });
945        schemas.extend(mcp_tools);
946    }
947    let items: Vec<Value> = schemas
948        .iter()
949        .filter_map(|t| {
950            let f = t.get("function")?;
951            Some(json!({
952                "id": f.get("name")?,
953                "description": f.get("description")?,
954                "parameters": f.get("parameters")?,
955            }))
956        })
957        .collect();
958    Ok(Json(Value::Array(items)))
959}
960
961/// GET /experimental/tool/ids — registry.ids(): plain string array.
962async fn get_experimental_tool_ids() -> Json<Value> {
963    let ids: Vec<Value> = ocserve_tools::schemas()
964        .iter()
965        .filter_map(|t| t.pointer("/function/name").and_then(|n| n.as_str()))
966        .map(|n| json!(n))
967        .collect();
968    Json(Value::Array(ids))
969}
970
971async fn experimental_resource() -> Json<Value> {
972    Json(json!({}))
973}
974async fn experimental_workspace() -> Json<Value> {
975    Json(json!([]))
976}
977
978/// GET /experimental/workspace/status — freeze: `[]` 200 (probed live).
979async fn experimental_workspace_status() -> Json<Value> {
980    Json(json!([]))
981}
982async fn formatter_list() -> Json<Value> {
983    Json(json!([]))
984}
985async fn lsp_list() -> Json<Value> {
986    Json(json!([]))
987}
988async fn project_directories() -> Json<Value> {
989    Json(json!([]))
990}
991
992/// GET /mcp — {name: {status, error?}} (freeze capture §1146).
993async fn get_mcp(State(st): State<Arc<AppState>>) -> Json<Value> {
994    let hub = st
995        .mcp
996        .get()
997        .cloned()
998        .unwrap_or_else(|| std::sync::Arc::new(ocserve_mcp::McpHub::default()));
999    Json(hub.statuses())
1000}
1001
1002/// POST /mcp (v1 `mcp.add`) — dynamically register an MCP server, then return
1003/// the full status map (freeze probed 2026-10-09: `{name:{status,error?}}`).
1004/// Missing `name`/`config` → 400 `{"name":"BadRequest","data":{message,kind}}`.
1005/// The server is added via the same hub path the config hot-reload uses
1006/// (upsert_cfg + connect), so a subsequently-listed server is live.
1007async fn post_mcp(
1008    State(st): State<Arc<AppState>>,
1009    Json(body): Json<Value>,
1010) -> Result<Json<Value>, HttpError> {
1011    let name = body
1012        .get("name")
1013        .and_then(|v| v.as_str())
1014        .filter(|s| !s.is_empty())
1015        .ok_or_else(|| mcp_payload_err("name"))?;
1016    let config = body
1017        .get("config")
1018        .filter(|c| c.is_object())
1019        .ok_or_else(|| mcp_payload_err("config"))?;
1020    let hub = st.mcp.get().cloned().ok_or_else(|| {
1021        HttpError::Api(ApiError {
1022            status: StatusCode::SERVICE_UNAVAILABLE,
1023            name: "McpUnavailable",
1024            message: "MCP hub not initialized".into(),
1025        })
1026    })?;
1027    // Build a single-entry `mcp` map and reuse the exact config parser so the
1028    // POST shape and the config-file shape can never diverge.
1029    let mut one = serde_json::Map::new();
1030    one.insert(name.to_string(), config.clone());
1031    let cfg = ocserve_mcp::parse_config(&Value::Object(one))
1032        .into_iter()
1033        .next()
1034        .ok_or_else(|| mcp_payload_err("config"))?;
1035    hub.upsert_cfg(cfg);
1036    // connect failure is recorded in the status map (freeze returns the map
1037    // with a `failed` entry, HTTP 200) — never a 500 for an unreachable server.
1038    if let Err(e) = hub.connect(name).await {
1039        tracing::info!("mcp add: connect {name} failed (recorded): {e:#}");
1040    }
1041    Ok(Json(hub.statuses()))
1042}
1043
1044/// Freeze `McpPaths.status` payload error: `Missing key at ["<field>"]`.
1045fn mcp_payload_err(field: &str) -> HttpError {
1046    HttpError::Payload {
1047        message: format!("Missing key\n  at [\"{field}\"]"),
1048    }
1049}
1050
1051/// Boundary wire-decode for v1 routes: freeze renders decode failures as
1052/// `{"name":"BadRequest","data":{"message":…,"kind":"Payload"}}` [400]
1053/// (field-probes.md). Decode runs FIRST — before session-existence checks
1054/// (probe: `{}` on a nonexistent session → 400, not 404).
1055pub(crate) fn decode_v1<T>(r: Result<T, ocserve_core::wire::WireError>) -> Result<T, HttpError> {
1056    r.map_err(|e| HttpError::Payload { message: e.message })
1057}
1058
1059/// Boundary wire-decode for v2 `/api/*` routes: freeze renders
1060/// `{"_tag":"InvalidRequestError","message":…,"kind":"Payload"}` [400]
1061/// (field-probes.md — differs from v1 on BOTH tag and nesting).
1062pub(crate) fn decode_v2<T>(r: Result<T, ocserve_core::wire::WireError>) -> Result<T, HttpError> {
1063    r.map_err(|e| HttpError::TaggedData {
1064        status: StatusCode::BAD_REQUEST,
1065        tag: "InvalidRequestError",
1066        fields: json!({"message": e.message, "kind": "Payload"}),
1067    })
1068}
1069
1070/// GET /experimental/session?search=&roots=&limit= — session title search
1071/// (oc-remote searchSessions: "not a content search"); rows = list wire shape
1072/// + embedded project {id, worktree} (observed contract, manifest keys mode).
1073async fn get_experimental_sessions(
1074    State(st): State<Arc<AppState>>,
1075    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
1076) -> Json<Value> {
1077    let mut v = run_blocking(&st.db, ocserve_store::load_sessions_wire)
1078        .await
1079        .unwrap_or_else(|e| {
1080            tracing::error!("session list read failed: {e:#}");
1081            Vec::new()
1082        });
1083    if let Some(search) = q.get("search").filter(|s| !s.is_empty()) {
1084        let needle = search.to_lowercase();
1085        v.retain(|s| {
1086            s["title"]
1087                .as_str()
1088                .unwrap_or_default()
1089                .to_lowercase()
1090                .contains(&needle)
1091        });
1092    }
1093    if let Some(limit) = q.get("limit").and_then(|l| l.parse::<usize>().ok()) {
1094        v.truncate(limit);
1095    }
1096    // roots=true is implicit: our store holds root sessions only (M3 scope).
1097    for row in &mut v {
1098        row["project"] = json!({"id": "global", "worktree": "/"});
1099    }
1100    Json(Value::Array(v))
1101}
1102
1103/// POST /permission/{id}/reply — {reply: once|always|reject} (oc-remote contract).
1104/// Body decode precedes the 404 (freeze probe: `{}` → 400 Payload "Missing key
1105/// at [reply]"; `{"reply":"maybe"}`/`{"reply":123}` → 400 Payload "Expected
1106/// once | always | reject"). The old `unwrap_or("reject")` silently coerced a
1107/// malformed body into a REJECT of a real pending ask and returned 404 for
1108/// unknown ids instead of the decode 400.
1109async fn post_permission_reply(
1110    State(st): State<Arc<AppState>>,
1111    axum::extract::Path(id): axum::extract::Path<String>,
1112    Json(body): Json<Value>,
1113) -> Result<Json<Value>, HttpError> {
1114    let reply = validate_permission_reply(&body)?;
1115    if !st.gate.reply(&id, reply) {
1116        return Err(HttpError::TaggedData {
1117            status: StatusCode::NOT_FOUND,
1118            tag: "PermissionNotFoundError",
1119            fields: json!({"requestID": id, "message": format!("Permission request not found: {id}")}),
1120        });
1121    }
1122    Ok(Json(json!({})))
1123}
1124
1125/// Validate a permission reply enum with the freeze Payload-decode error
1126/// messages (order matters: missing key first, then the union value).
1127pub(crate) fn validate_permission_reply(body: &Value) -> Result<&'static str, HttpError> {
1128    validate_permission_reply_key(body, "reply")
1129}
1130
1131/// Same, for the deprecated `{response: …}` body key.
1132pub(crate) fn validate_permission_reply_key(
1133    body: &Value,
1134    key: &str,
1135) -> Result<&'static str, HttpError> {
1136    let Some(v) = body.get(key) else {
1137        return Err(HttpError::Payload {
1138            message: format!("Missing key\n  at [\"{key}\"]"),
1139        });
1140    };
1141    match v.as_str() {
1142        Some("once") => Ok("once"),
1143        Some("always") => Ok("always"),
1144        Some("reject") => Ok("reject"),
1145        // freeze renders the offending value with JSON syntax (strings quoted,
1146        // numbers bare): `Expected "once" | "always" | "reject", got 123`.
1147        _ => Err(HttpError::Payload {
1148            message: format!(
1149                "Expected \"once\" | \"always\" | \"reject\", got {}\n  at [\"{key}\"]",
1150                v
1151            ),
1152        }),
1153    }
1154}
1155
1156/// GET /permission — pending permission requests.
1157async fn get_permissions(State(st): State<Arc<AppState>>) -> Json<Value> {
1158    Json(serde_json::Value::Array(st.gate.list()))
1159}
1160
1161/// GET /vcs — {branch, default_branch} (values cwd-dependent; keys golden).
1162async fn vcs_info(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1163    let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
1164    let info = run_blocking(&st.db, move |_db| Ok::<_, anyhow::Error>(vcs::info(&dir)))
1165        .await
1166        .unwrap_or_else(|_| json!({"branch": null, "default_branch": null}));
1167    Json(info)
1168}
1169
1170/// GET /vcs/status → FileStatus[] (freeze shape; [] when not a git repo).
1171async fn vcs_status(State(st): State<Arc<AppState>>) -> Response {
1172    let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
1173    match run_blocking(&st.db, move |_db| Ok::<_, anyhow::Error>(vcs::status(&dir))).await {
1174        Ok(v) => axum::Json(v).into_response(),
1175        Err(_) => axum::Json(Value::Array(vec![])).into_response(),
1176    }
1177}
1178
1179/// GET /vcs/diff?mode=git|branch[&context=N] → FileDiff[].
1180/// Missing/other mode → 400 Effect Query envelope (freeze probe).
1181async fn vcs_diff(
1182    State(st): State<Arc<AppState>>,
1183    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
1184    headers: axum::http::HeaderMap,
1185) -> Response {
1186    let mode = q.get("mode").map(String::as_str).unwrap_or("");
1187    if mode != "git" && mode != "branch" {
1188        return HttpError::Query {
1189            message: format!("Expected \"git\" | \"branch\", got \"{mode}\"\n  at [\"mode\"]"),
1190        }
1191        .into_response();
1192    }
1193    let context: Option<u32> = q.get("context").and_then(|v| v.parse().ok());
1194    let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
1195    let mode_owned = mode.to_string();
1196    let v = run_blocking(&st.db, move |_db| {
1197        Ok::<_, anyhow::Error>(vcs::diff(&dir, &mode_owned, context))
1198    })
1199    .await
1200    .unwrap_or_else(|_| Value::Array(vec![]));
1201    let ae = headers
1202        .get(axum::http::header::ACCEPT_ENCODING)
1203        .and_then(|v| v.to_str().ok());
1204    let inm = headers
1205        .get(axum::http::header::IF_NONE_MATCH)
1206        .and_then(|v| v.to_str().ok());
1207    let body = serde_json::to_vec(&v).unwrap_or_default();
1208    let etag = crate::compress::etag_for(&body);
1209    crate::compress::encode_response(
1210        StatusCode::OK,
1211        "application/json",
1212        bytes::Bytes::from(body),
1213        Some(&etag),
1214        ae,
1215        inm,
1216        &[],
1217    )
1218}
1219
1220/// GET /vcs/diff/raw → raw patch text (empty string when clean), bounded by the
1221/// same 10 MB cap the tracked diff uses (was unbounded — a large dirty tree
1222/// allocated the whole patch on a request).
1223async fn vcs_diff_raw(State(st): State<Arc<AppState>>) -> Response {
1224    let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
1225    let (text, truncated) = run_blocking(&st.db, move |_db| {
1226        Ok::<_, anyhow::Error>(vcs::diff_raw_bounded(&dir))
1227    })
1228    .await
1229    .unwrap_or_else(|_| (String::new(), false));
1230    Response::builder()
1231        .header(
1232            axum::http::header::CONTENT_TYPE,
1233            "text/plain; charset=utf-8",
1234        )
1235        .header(axum::http::header::VARY, "Accept-Encoding")
1236        .header("x-ocserve-truncated", if truncated { "1" } else { "0" })
1237        .body(axum::body::Body::from(text))
1238        .expect("static response")
1239}
1240
1241/// GET /file/status → [] (freeze returns the changed-file set from the
1242/// snapshot engine; with no snapshot engine the honest shape is empty —
1243/// the web UI only checks length>0 for a dirty badge, which `vcs/status`
1244/// now serves correctly).
1245async fn file_status() -> impl IntoResponse {
1246    axum::Json(Value::Array(vec![]))
1247}
1248
1249/// GET /find/symbol → [] (freeze returns LSP symbols; no LSP server →
1250/// empty, the same shape clients tolerate).
1251async fn find_symbol() -> impl IntoResponse {
1252    axum::Json(Value::Array(vec![]))
1253}
1254
1255/// GET /global/config → the effective merged config (freeze shape: same
1256/// keys as /config minus the per-instance `agent`/`command`/`mode`/`username`
1257/// overrides — probed key diff 2026-10-08). Served from the wire cache.
1258async fn global_config_get(
1259    State(st): State<Arc<AppState>>,
1260    headers: axum::http::HeaderMap,
1261) -> Response {
1262    let ae = headers
1263        .get(axum::http::header::ACCEPT_ENCODING)
1264        .and_then(|v| v.to_str().ok());
1265    let inm = headers
1266        .get(axum::http::header::IF_NONE_MATCH)
1267        .and_then(|v| v.to_str().ok());
1268    // Freeze /global/config == /config with the instance-scoped keys absent.
1269    // Build it once per reload into the wire cache under "global_config".
1270    wire_json_h(&st, "global_config", ae, inm).unwrap_or_else(|| {
1271        let mut v = st.payloads.read().config.clone();
1272        if let Some(obj) = v.as_object_mut() {
1273            for k in ["agent", "command", "mode", "username"] {
1274                obj.remove(k);
1275            }
1276        }
1277        let body = serde_json::to_vec(&v).unwrap_or_default();
1278        let etag = crate::compress::etag_for(&body);
1279        crate::compress::encode_response(
1280            StatusCode::OK,
1281            "application/json",
1282            bytes::Bytes::from(body),
1283            Some(&etag),
1284            ae,
1285            inm,
1286            &[],
1287        )
1288    })
1289}
1290
1291// ---- PERF-10X F9: paged /message wire memo ----
1292//
1293// message_page was the CPU hog (route isolation: ~3 ms CPU/page — parse +
1294// merge + serialize per message, blocking-pool thread, channel framing).
1295// A paged response (limit>0, ≤50 messages) is assembled ONCE per
1296// (db, session, limit, cursor, write-epoch) and served as refcounted
1297// Bytes; hits skip reader, queries, parse, thread and channel entirely.
1298// Bound by construction: entries ≤ 4 MiB (larger assemblies abort and
1299// fall back to the original streaming path — the unbounded full-history
1300// stream is NEVER memoized, AGENTS §2.3), FIFO capped at 32 entries.
1301// Exactness: keys carry the write epoch; a writer bump invalidates
1302// (lazy clear on epoch mismatch — same rule as F7/F8). Kill switch:
1303// OCSERVE_PAGE_MEMO=0.
1304pub const PAGE_MEMO_ENTRY_CAP: usize = 4 * 1024 * 1024;
1305/// Count + BYTE bounds (F9c): the first cap (32) thrashed against the
1306/// fixture's 200-session spread working set (VU150/300 spread fell to
1307/// 3,794 rps, p95 278ms — every rotation missed and paid the full
1308/// assemble path; arrival never recovered from it). Working set ≈
1309/// sessions x pages-per-loop ≈ 600 keys; typical pages are 10-50KB so a
1310/// 64 MiB total budget holds the whole set (env: OCSERVE_PAGE_MEMO_MB).
1311pub const PAGE_MEMO_MAX_ENTRIES: usize = 2048;
1312pub const PAGE_MEMO_MAX_BYTES: usize = 64 * 1024 * 1024;
1313
1314struct PageEntry {
1315    body: bytes::Bytes,
1316    next: Option<String>,
1317}
1318
1319struct PageMemoState {
1320    epoch: u64,
1321    entries: std::collections::VecDeque<(String, PageEntry)>,
1322    bytes: usize,
1323}
1324
1325impl PageMemoState {
1326    fn new() -> Self {
1327        Self {
1328            epoch: 0,
1329            entries: std::collections::VecDeque::new(),
1330            bytes: 0,
1331        }
1332    }
1333    fn get(&mut self, key: &str, epoch: u64) -> Option<PageEntry> {
1334        if self.epoch != epoch {
1335            self.entries.clear();
1336            self.bytes = 0;
1337            self.epoch = epoch;
1338        }
1339        self.entries
1340            .iter()
1341            .find(|(k, _)| k == key)
1342            .map(|(_, e)| PageEntry {
1343                body: e.body.clone(),
1344                next: e.next.clone(),
1345            })
1346    }
1347    fn put(&mut self, key: String, entry: PageEntry, epoch: u64) {
1348        if entry.body.len() > PAGE_MEMO_ENTRY_CAP.min(PAGE_MEMO_MAX_BYTES) {
1349            return;
1350        }
1351        if self.epoch != epoch {
1352            self.entries.clear();
1353            self.bytes = 0;
1354            self.epoch = epoch;
1355        }
1356        // drop same-key old copy (byte accounting)
1357        if let Some(pos) = self.entries.iter().position(|(k, _)| k == &key) {
1358            let (_, old) = self.entries.remove(pos).expect("position found");
1359            self.bytes -= old.body.len();
1360        }
1361        // RAM audit 2026-10-11: the env budget was unbounded above — a large
1362        // OCSERVE_PAGE_MEMO_MB could hold arbitrarily many bytes, and a single
1363        // entry larger than the budget was kept alone (len() == 1 skips the
1364        // eviction loop, so `bytes > budget` with no victim). Clamp the
1365        // override to the const ceiling and refuse entries that alone exceed
1366        // it (entry cap above already bounds each, so a refused entry is
1367        // simply not memoized — the route still serves from the DB).
1368        let budget = std::env::var("OCSERVE_PAGE_MEMO_MB")
1369            .ok()
1370            .and_then(|v| v.parse::<usize>().ok())
1371            .map(|mb| mb * 1024 * 1024)
1372            .unwrap_or(PAGE_MEMO_MAX_BYTES)
1373            .min(PAGE_MEMO_MAX_BYTES);
1374        self.bytes += entry.body.len();
1375        self.entries.push_back((key, entry));
1376        while self.entries.len() > PAGE_MEMO_MAX_ENTRIES
1377            || (self.bytes > budget && self.entries.len() > 1)
1378        {
1379            if let Some((_, old)) = self.entries.pop_front() {
1380                self.bytes -= old.body.len();
1381            } else {
1382                break;
1383            }
1384        }
1385    }
1386    fn len(&self) -> usize {
1387        self.entries.len()
1388    }
1389}
1390
1391static PAGE_MEMO: std::sync::OnceLock<parking_lot::Mutex<PageMemoState>> =
1392    std::sync::OnceLock::new();
1393static PAGE_MEMO_HITS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1394static PAGE_MEMO_MISSES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1395
1396fn page_memo() -> &'static parking_lot::Mutex<PageMemoState> {
1397    PAGE_MEMO.get_or_init(|| parking_lot::Mutex::new(PageMemoState::new()))
1398}
1399
1400pub fn page_memo_enabled() -> bool {
1401    std::env::var("OCSERVE_PAGE_MEMO")
1402        .map(|v| v != "0")
1403        .unwrap_or(true)
1404}
1405
1406/// (hits, misses, entries) for the load report / tests.
1407pub fn page_memo_stats() -> (u64, u64, usize) {
1408    (
1409        PAGE_MEMO_HITS.load(std::sync::atomic::Ordering::Relaxed),
1410        PAGE_MEMO_MISSES.load(std::sync::atomic::Ordering::Relaxed),
1411        page_memo().lock().len(),
1412    )
1413}
1414
1415/// Page response builder shared by the memo path and the streaming path
1416/// (headers rule = upstream session.ts:133-147: Link echoes the request
1417/// origin — Host / x-forwarded-proto — so headers are rebuilt per request
1418/// even on a memo hit; only the BODY is cached).
1419fn page_response(
1420    headers: &axum::http::HeaderMap,
1421    uri: &axum::http::Uri,
1422    page_n: &Option<u64>,
1423    page_next: &Option<String>,
1424    body: axum::body::Body,
1425) -> Result<axum::response::Response, ApiError> {
1426    let mut builder = axum::response::Response::builder()
1427        .header(axum::http::header::CONTENT_TYPE, "application/json");
1428    if let (Some(n), Some(cur)) = (page_n, page_next) {
1429        let scheme = headers
1430            .get("x-forwarded-proto")
1431            .and_then(|v| v.to_str().ok())
1432            .unwrap_or("http");
1433        let host = headers
1434            .get("host")
1435            .and_then(|v| v.to_str().ok())
1436            .unwrap_or("localhost");
1437        let link = format!(
1438            "<{scheme}://{host}{}?limit={n}&before={cur}>; rel=\"next\"",
1439            uri.path()
1440        );
1441        builder = builder
1442            .header("x-next-cursor", cur.as_str())
1443            .header("link", link)
1444            .header("access-control-expose-headers", "Link, X-Next-Cursor");
1445    }
1446    builder.body(body).map_err(|e| ApiError {
1447        status: StatusCode::INTERNAL_SERVER_ERROR,
1448        name: "InternalError",
1449        message: format!("response build: {e}"),
1450    })
1451}
1452
1453async fn get_sessions(
1454    State(st): State<Arc<AppState>>,
1455    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
1456    headers: axum::http::HeaderMap,
1457) -> axum::response::Response {
1458    // W4: freeze ListQuery parity (limit default 100, roots, search, start,
1459    // directory, scope=project). The bare list keeps the exact single-slot
1460    // memo path (byte-identical); filtered variants use a param-keyed memo.
1461    // Fresh DB rows (upstream semantics) — the boot map was stale after
1462    // prompts (M2 finding); store orders by time_updated DESC.
1463    let query = ocserve_store::SessionListQuery {
1464        limit: q
1465            .get("limit")
1466            .and_then(|v| v.parse::<usize>().ok())
1467            .filter(|n| *n > 0),
1468        roots: q.get("roots").map(|v| v == "true").unwrap_or(false),
1469        search: q.get("search").filter(|s| !s.is_empty()).cloned(),
1470        start: q.get("start").and_then(|v| v.parse::<i64>().ok()),
1471        directory: q.get("directory").filter(|s| !s.is_empty()).cloned(),
1472        scope_project: q.get("scope").map(|v| v == "project").unwrap_or(false),
1473    };
1474    let ae = headers
1475        .get(axum::http::header::ACCEPT_ENCODING)
1476        .and_then(|v| v.to_str().ok());
1477    let inm = headers
1478        .get(axum::http::header::IF_NONE_MATCH)
1479        .and_then(|v| v.to_str().ok());
1480    let qc = query.clone();
1481    match run_blocking(&st.db, move |db| {
1482        ocserve_store::load_sessions_wire_bytes_filtered(db, &qc)
1483    })
1484    .await
1485    {
1486        Ok(b) => {
1487            let etag = crate::compress::etag_for(&b);
1488            crate::compress::encode_response(
1489                axum::http::StatusCode::OK,
1490                "application/json",
1491                (*b).clone(),
1492                Some(&etag),
1493                ae,
1494                inm,
1495                &[],
1496            )
1497        }
1498        Err(e) => {
1499            tracing::error!("session list read failed: {e:#}");
1500            Json(Vec::<serde_json::Value>::new()).into_response()
1501        }
1502    }
1503}
1504
1505async fn get_session(
1506    State(st): State<Arc<AppState>>,
1507    axum::extract::Path(id): axum::extract::Path<String>,
1508) -> Result<impl IntoResponse, ApiError> {
1509    let wire_id = id.clone();
1510    run_blocking(&st.db, move |db| {
1511        ocserve_store::load_session_wire(db, &wire_id)
1512    })
1513    .await
1514    .map_err(|e| ApiError {
1515        status: StatusCode::INTERNAL_SERVER_ERROR,
1516        name: "InternalError",
1517        message: format!("{e:#}"),
1518    })?
1519    .map(Json)
1520    .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))
1521}
1522
1523/// GET /session/status — map of NON-idle sessions only (upstream
1524/// session/status.ts deletes idle entries; probe fixture: status_busy.body).
1525/// Busy = held/contended prompt lock OR a live background prompt task —
1526/// queued-but-not-yet-running prompts count (client's send is admitted).
1527async fn session_status(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1528    let mut out = serde_json::Map::new();
1529    let mut busy = |sid: &str| {
1530        out.insert(sid.to_string(), serde_json::json!({"type": "busy"}));
1531    };
1532    for (sid, arc) in st.prompt_locks.lock().iter() {
1533        if arc.try_lock().is_err() {
1534            busy(sid);
1535        }
1536    }
1537    for (sid, (_gen, handle)) in st.prompt_tasks.lock().iter() {
1538        if !handle.is_finished() {
1539            busy(sid);
1540        }
1541    }
1542    Json(serde_json::Value::Object(out))
1543}
1544
1545/// GET /path — env-derived at boot (upstream keys: home/state/config/worktree/directory).
1546async fn get_path(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1547    Json(st.paths.clone())
1548}
1549
1550/// GET /project — the global project + one project per distinct session
1551/// directory (newest first). freeze derives its project list from the worktree
1552/// registry; ocserve derives it from stored session rows (documented
1553/// derivation divergence, same wire shape). This is what lets the web UI group
1554/// sessions under the directories they actually belong to — with only
1555/// `global`+`current`, sessions under other directories were dropped from the
1556/// sidebar ("Nothing here yet").
1557async fn get_projects(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1558    let now_ms = std::time::SystemTime::now()
1559        .duration_since(std::time::UNIX_EPOCH)
1560        .map(|d| d.as_millis() as i64)
1561        .unwrap_or(0);
1562    let mut dirs = ocserve_store::distinct_session_directories(&st.db).unwrap_or_default();
1563    // ensure the server worktree is always present even on an empty/fresh DB
1564    let worktree = st.paths["worktree"].as_str().unwrap_or("/").to_string();
1565    if !dirs.iter().any(|d| d == &worktree) {
1566        dirs.insert(0, worktree);
1567    }
1568    let project = |id: &str, wt: &str| {
1569        json!({
1570            "id": id,
1571            "worktree": wt,
1572            "time": {"created": now_ms, "updated": now_ms, "initialized": now_ms},
1573            "sandboxes": [],
1574        })
1575    };
1576    let mut out = vec![project("global", "/")];
1577    for d in dirs {
1578        out.push(project(&d, &d));
1579    }
1580    Json(Value::Array(out))
1581}
1582
1583/// GET /project/current — same shape, single object.
1584async fn get_project_current(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1585    let now_ms = std::time::SystemTime::now()
1586        .duration_since(std::time::UNIX_EPOCH)
1587        .map(|d| d.as_millis() as i64)
1588        .unwrap_or(0);
1589    let worktree = st.paths["worktree"].as_str().unwrap_or("/");
1590    Json(json!({
1591        "id": "global",
1592        "worktree": worktree,
1593        "time": {"created": now_ms, "updated": now_ms, "initialized": now_ms},
1594        "sandboxes": [],
1595    }))
1596}
1597
1598/// Prometheus text format for the KPI series (SRE §2). Internal bind only.
1599async fn metrics(State(_st): State<Arc<AppState>>) -> impl IntoResponse {
1600    // registry counters are incremented by the timing middleware; rss sampled
1601    // here (scrape-time) so gauges are fresh
1602    ocserve_metrics::sample_rss();
1603    // Read-path fast activity (L1 splice + F5/F7/F8 memos), published as
1604    // absolute cumulative gauges at scrape time — these counters live in
1605    // ocserve-store as atomics with no event stream of their own.
1606    //  - splice_rows/fallbacks: the corpus gate proves 0 fallbacks on real
1607    //    data, so a non-zero fallback gauge means stored bytes hit a shape
1608    //    the splicer refuses — a signal, not a silent fallback (lib.rs L1).
1609    //  - memo hit/miss: the load report's cache-on/off variants must be
1610    //    observable in production, not just in a bench run.
1611    ocserve_metrics::gauge(
1612        "ocserve_splice_rows_total",
1613        ocserve_store::splice_rows() as i64,
1614    );
1615    ocserve_metrics::gauge(
1616        "ocserve_splice_fallbacks_total",
1617        ocserve_store::splice_fallbacks() as i64,
1618    );
1619    let (search_hits, search_misses, list_hits, list_misses, _pad, search_len, _epoch) =
1620        ocserve_store::memo_stats();
1621    ocserve_metrics::gauge("ocserve_memo_search_hits", search_hits as i64);
1622    ocserve_metrics::gauge("ocserve_memo_search_misses", search_misses as i64);
1623    ocserve_metrics::gauge("ocserve_memo_list_hits", list_hits as i64);
1624    ocserve_metrics::gauge("ocserve_memo_list_misses", list_misses as i64);
1625    ocserve_metrics::gauge("ocserve_memo_search_entries", search_len as i64);
1626    let body = ocserve_metrics::render();
1627    (
1628        [(
1629            axum::http::header::CONTENT_TYPE,
1630            "text/plain; version=0.0.4",
1631        )],
1632        body,
1633    )
1634}
1635
1636// ---- M2: session create + sync prompt + message list ----
1637
1638/// Two-word slug (upstream generates like "stellar-orchid" — entropy from id).
1639fn slug_for(id: &str) -> String {
1640    const A: &[&str] = &[
1641        "stellar", "amber", "quiet", "cobalt", "rapid", "lunar", "vivid", "crisp", "amber", "ivory",
1642    ];
1643    const B: &[&str] = &[
1644        "orchid", "falcon", "meadow", "canyon", "ember", "drift", "quartz", "harbor", "summit",
1645        "ripple",
1646    ];
1647    let h = id
1648        .bytes()
1649        .fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64));
1650    format!(
1651        "{}-{}",
1652        A[(h % A.len() as u64) as usize],
1653        B[((h >> 16) % B.len() as u64) as usize]
1654    )
1655}
1656
1657fn now_ms() -> i64 {
1658    std::time::SystemTime::now()
1659        .duration_since(std::time::UNIX_EPOCH)
1660        .map(|d| d.as_millis() as i64)
1661        .unwrap_or(0)
1662}
1663
1664/// ISO-8601 UTC with milliseconds (freeze `Date.prototype.toISOString`
1665/// shape) — days-from-civil, no time-crate dependency.
1666fn iso8601_z(ms: i64) -> String {
1667    let ms = ms.rem_euclid(86_400_000_000_000);
1668    let (days, rem) = (ms.div_euclid(86_400_000), ms.rem_euclid(86_400_000));
1669    let (h, rem) = (rem.div_euclid(3_600_000), rem.rem_euclid(3_600_000));
1670    let (mi, rem) = (rem.div_euclid(60_000), rem.rem_euclid(60_000));
1671    let (sec, milli) = (rem.div_euclid(1_000), rem.rem_euclid(1_000));
1672    // civil_from_days (Howard Hinnant)
1673    let z = days + 719_468;
1674    let era = z.div_euclid(146_097);
1675    let doe = z.rem_euclid(146_097);
1676    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
1677    let y = yoe + era * 400;
1678    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1679    let mp = (5 * doy + 2) / 153;
1680    let d = doy - (153 * mp + 2) / 5 + 1;
1681    let m = if mp < 10 { mp + 3 } else { mp - 9 };
1682    let y = if m <= 2 { y + 1 } else { y };
1683    format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{sec:02}.{milli:03}Z")
1684}
1685
1686/// Freeze default session title (session.ts `parentTitlePrefix + ISO`) —
1687/// JS `Date.prototype.toDateString()` ("Mon Oct 12 2026") — the env block's
1688/// "Today's date" line (system.ts:environment). Days-from-civil, no date crate.
1689pub fn js_to_date_string(ms: i64) -> String {
1690    const WDAY: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
1691    const MON: [&str; 12] = [
1692        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
1693    ];
1694    let days = ms.div_euclid(86_400_000);
1695    let z = days + 719_468;
1696    let era = z.div_euclid(146_097);
1697    let doe = z.rem_euclid(146_097);
1698    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
1699    let y = yoe + era * 400;
1700    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1701    let mp = (5 * doy + 2) / 153;
1702    let d = doy - (153 * mp + 2) / 5 + 1;
1703    let m = if mp < 10 { mp + 3 } else { mp - 9 } as usize;
1704    let y = if m <= 2 { y + 1 } else { y };
1705    // 1970-01-01 was a Thursday (weekday 4; JS Sun=0)
1706    let wd = (days + 4).rem_euclid(7) as usize;
1707    format!("{} {} {:02} {}", WDAY[wd], MON[m - 1], d, y)
1708}
1709
1710fn default_session_title(now_ms: i64) -> String {
1711    format!("New session - {}", iso8601_z(now_ms))
1712}
1713
1714/// Freeze `getForkedTitle` (session.ts): `/^(.+) \(fork #(\d+)\)$/` →
1715/// `base (fork #(n+1))`, otherwise `title (fork #1)`. Ported with string ops
1716/// (regex `.+` needs ≥1 char before the suffix → `idx > 0`, greedy match →
1717/// last occurrence → `rfind`).
1718pub fn forked_title(title: &str) -> String {
1719    const PAT: &str = " (fork #";
1720    if let Some(idx) = title.rfind(PAT)
1721        && idx > 0
1722        && let Some(num) = title[idx + PAT.len()..].strip_suffix(')')
1723        && !num.is_empty()
1724        && num.bytes().all(|b| b.is_ascii_digit())
1725        && let Ok(n) = num.parse::<u64>()
1726    {
1727        return format!("{} (fork #{})", &title[..idx], n + 1);
1728    }
1729    format!("{title} (fork #1)")
1730}
1731
1732/// P0e: forward bus frames to the plugin sidecar's `event` hook (v1
1733/// plugin/index.ts:255-259). Lagged frames are skipped (upstream is
1734/// fire-and-forget); a closed bus ends the pump. Never blocks publishers.
1735pub fn start_plugin_event_pump(st: &Arc<AppState>) {
1736    // Subscribe SYNCHRONOUSLY here: last_seq is captured at call time, so
1737    // frames published between call and first recv are buffered (drain), not
1738    // skipped. (Spawning first raced: sync route handlers can publish before
1739    // the task ever ran — caught by the pump test.)
1740    let mut rx = st.bus.subscribe();
1741    let st = st.clone();
1742    tokio::spawn(async move {
1743        loop {
1744            match rx.recv().await {
1745                Ok(frame_str) => {
1746                    let Some(plug) = st.plugins.get() else {
1747                        continue; // sidecar not loaded — frames still on SSE
1748                    };
1749                    let Ok(v) = serde_json::from_str::<Value>(&frame_str) else {
1750                        continue;
1751                    };
1752                    let Some(payload) = v.get("payload").cloned() else {
1753                        continue;
1754                    };
1755                    let mut guard = plug.lock().await;
1756                    if let Err(e) = guard.emit_event(payload).await {
1757                        tracing::debug!("plugin event pump: {e:#}");
1758                    }
1759                }
1760                Err(ocserve_core::event::RecvError::Lagged(n)) => {
1761                    tracing::warn!("plugin event pump lagged {n} frames (skipped)");
1762                }
1763                Err(ocserve_core::event::RecvError::Closed) => break,
1764            }
1765        }
1766    });
1767}
1768
1769/// POST /session — create (wire shape from captured fixture: cost, directory,
1770/// id, path, projectID, slug, time, title, tokens, version).
1771async fn post_session(
1772    State(st): State<Arc<AppState>>,
1773    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
1774    Json(body): Json<Value>,
1775) -> Result<impl IntoResponse, ApiError> {
1776    let resolved =
1777        resolve_create_dir_path(&st, &body, q.get("directory").map(String::as_str)).await;
1778    Ok(Json(create_session_record(&st, &body, resolved)?))
1779}
1780
1781/// Async directory resolution: the git toplevel lookup spawns a subprocess, so
1782/// it runs on the blocking pool (never pins an async worker — the M3 class).
1783async fn resolve_create_dir_path(
1784    st: &Arc<AppState>,
1785    body: &Value,
1786    query_dir: Option<&str>,
1787) -> (String, String) {
1788    let requested = resolve_create_dir(st, body, query_dir);
1789    let st = st.clone();
1790    tokio::task::spawn_blocking(move || freeze_dir_path(&requested))
1791        .await
1792        .unwrap_or_else(|_| {
1793            let d = resolve_create_dir(&st, body, query_dir);
1794            (d.clone(), d.trim_start_matches('/').to_string())
1795        })
1796}
1797
1798/// Resolve the create-time directory (freeze semantics): `directory` query
1799/// param (v1) wins, else `body.location.directory` (v2 create shape), else the
1800/// server worktree. Before this, ocserve ignored the requested directory and
1801/// stamped every session with the server cwd — the "session thinks it's in the
1802/// ocserve repo" + constant `external_directory` prompt class.
1803fn resolve_create_dir(st: &Arc<AppState>, body: &Value, query_dir: Option<&str>) -> String {
1804    query_dir
1805        .filter(|s| !s.is_empty())
1806        .map(String::from)
1807        .or_else(|| {
1808            body.pointer("/location/directory")
1809                .and_then(|d| d.as_str())
1810                .filter(|s| !s.is_empty())
1811                .map(String::from)
1812        })
1813        .unwrap_or_else(|| st.paths["worktree"].as_str().unwrap_or("/").to_string())
1814}
1815
1816/// freeze directory semantics (probed): if `dir` is inside a git repo, the
1817/// project `directory` is the repo toplevel and `path` is `dir` relative to it
1818/// (empty when equal, no leading slash); if it is NOT in a repo, `directory` is
1819/// `dir` itself and `path` is `dir` with the leading slash stripped (the cwd
1820/// relative to the global "/" project root).
1821/// Probed: a repo checkout → (toplevel, ""); a subdir of a repo →
1822/// (toplevel, "sub/dir"); a non-repo dir `/tmp/xyz` → ("/tmp/xyz", "tmp/xyz").
1823fn freeze_dir_path(dir: &str) -> (String, String) {
1824    let dir = dir.trim_end_matches('/');
1825    // `/` (or empty) is the global project root: no repo lookup — running
1826    // `git -C ""` would silently default to the SERVER cwd and mis-stamp the
1827    // session with the ocserve repo (the `?directory=/` env-block bug).
1828    if dir.is_empty() {
1829        return ("/".to_string(), String::new());
1830    }
1831    match git_toplevel(dir) {
1832        Some(root) => {
1833            let path = dir
1834                .strip_prefix(&root)
1835                .unwrap_or("")
1836                .trim_start_matches('/')
1837                .to_string();
1838            (root, path)
1839        }
1840        None => {
1841            let path = dir.trim_start_matches('/').to_string();
1842            (dir.to_string(), path)
1843        }
1844    }
1845}
1846
1847/// `git rev-parse --show-toplevel` for `dir` (no shell). None when the dir is
1848/// absent, not a repo, or git is unavailable.
1849fn git_toplevel(dir: &str) -> Option<String> {
1850    let out = std::process::Command::new("git")
1851        .arg("-C")
1852        .arg(dir)
1853        .args(["rev-parse", "--show-toplevel"])
1854        .output()
1855        .ok()?;
1856    if !out.status.success() {
1857        return None;
1858    }
1859    let root = String::from_utf8_lossy(&out.stdout).trim().to_string();
1860    (!root.is_empty()).then_some(root)
1861}
1862
1863/// Shared session creation — `POST /session` (v1) and `POST /api/session`
1864/// (v2) both land here. `worktree`/`rel_path` are the freeze-resolved project
1865/// root + cwd-relative path (see `freeze_dir_path`). Returns the created
1866/// Session.Info.
1867pub(crate) fn create_session_record(
1868    st: &Arc<AppState>,
1869    body: &Value,
1870    (worktree, rel_path): (String, String),
1871) -> Result<Value, ApiError> {
1872    let id = ocserve_core::ids::ses_id();
1873    let now = now_ms();
1874    let title = body
1875        .get("title")
1876        .and_then(|t| t.as_str())
1877        .unwrap_or("")
1878        .to_string();
1879    // K-TITLE: freeze default when the client sends none/empty — an empty
1880    // title renders as "untitled" everywhere (upstream: parentTitlePrefix).
1881    let title = if title.trim().is_empty() {
1882        default_session_title(now)
1883    } else {
1884        title
1885    };
1886    let agent = body
1887        .get("agent")
1888        .and_then(|v| v.as_str())
1889        .filter(|s| !s.is_empty())
1890        .unwrap_or("build");
1891    let model = body.get("model").cloned().unwrap_or(Value::Null);
1892    let mut info = json!({
1893        "id": id,
1894        "projectID": "global",
1895        "directory": worktree,
1896        "path": rel_path,
1897        "slug": slug_for(&id),
1898        "title": title,
1899        "version": FREEZE_VERSION,
1900        "time": {"created": now, "updated": now},
1901        "cost": 0,
1902        "tokens": {"input": 0, "output": 0, "reasoning": 0,
1903                   "cache": {"read": 0, "write": 0}},
1904    });
1905    if body.get("agent").is_some() {
1906        info["agent"] = json!(agent);
1907    }
1908    if model.is_object() {
1909        info["model"] = model;
1910    }
1911    ocserve_store::insert_session(&st.writer, &info).map_err(|e| ApiError {
1912        status: StatusCode::INTERNAL_SERVER_ERROR,
1913        name: "InternalError",
1914        message: format!("{e:#}"),
1915    })?;
1916    st.bus.publish(ocserve_core::event::frame(
1917        &worktree,
1918        "session.created",
1919        json!({"sessionID": id, "info": info}),
1920    ));
1921    Ok(info)
1922}
1923
1924/// POST /session/{id}/fork — freeze session.fork (groups/session.ts:248,
1925/// session.ts:691). Effect decodes the payload BEFORE the handler runs, so
1926/// payload errors precede the 404: bad JSON / non-object / non-string
1927/// `messageID` → 400 `{"_tag":"BadRequest"}`; empty or whitespace body is
1928/// the NoContent arm (copy all). Returns the NEW Session.Info; messages are
1929/// copied strictly before `messageID` (freeze `slice(0, findIndex)`; unknown
1930/// id → findIndex -1 → copy all).
1931async fn post_fork(
1932    State(st): State<Arc<AppState>>,
1933    axum::extract::Path(id): axum::extract::Path<String>,
1934    body: axum::body::Bytes,
1935) -> Result<Json<Value>, HttpError> {
1936    let upto: Option<String> = {
1937        let text = String::from_utf8_lossy(&body);
1938        if text.trim().is_empty() {
1939            None // NoContent arm
1940        } else {
1941            let v: Value = serde_json::from_str(&text).map_err(|_| tagged_bad_request())?;
1942            if !v.is_object() {
1943                return Err(tagged_bad_request()); // [] / "x" / 42 / null
1944            }
1945            match v.get("messageID") {
1946                None => None,
1947                Some(Value::String(s)) => Some(s.clone()),
1948                Some(_) => return Err(tagged_bad_request()), // non-string / null
1949            }
1950        }
1951    };
1952    let src_id = id.clone();
1953    let src = run_blocking(&st.db, move |db| {
1954        ocserve_store::load_session_wire(db, &src_id)
1955    })
1956    .await
1957    .map_err(|e| ApiError {
1958        status: StatusCode::INTERNAL_SERVER_ERROR,
1959        name: "InternalError",
1960        message: format!("{e:#}"),
1961    })?
1962    .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))?;
1963
1964    let new_id = ocserve_core::ids::ses_id();
1965    let now = now_ms();
1966    let worktree = st.paths["worktree"].as_str().unwrap_or("/").to_string();
1967    // createNext shape (session.ts): fresh id/slug, FORKED title, cost/tokens
1968    // zeroed (fork does not inherit usage), request-ctx directory/path —
1969    // workspaceID/metadata are not modeled by ocserve's schema (pre-existing).
1970    let info = json!({
1971        "id": new_id,
1972        "projectID": "global",
1973        "directory": worktree,
1974        "path": worktree.trim_start_matches('/'),
1975        "slug": slug_for(&new_id),
1976        "title": forked_title(src["title"].as_str().unwrap_or("")),
1977        "version": FREEZE_VERSION,
1978        "time": {"created": now, "updated": now},
1979        "cost": 0,
1980        "tokens": {"input": 0, "output": 0, "reasoning": 0,
1981                   "cache": {"read": 0, "write": 0}},
1982    });
1983    // PERF-10X F1: fork copies the whole source session (32k msgs measured)
1984    // — inline it parked a worker for the entire copy.
1985    let writer = st.writer.clone();
1986    let blobs = st.blobs.clone();
1987    let db = st.db.clone();
1988    let fork_info = info.clone();
1989    let fork_id = id.clone();
1990    let fork_upto = upto.clone();
1991    let stats = run_blocking(&db, move |db| {
1992        let env = ocserve_store::fork::ForkEnv {
1993            writer: &writer,
1994            blobs: Some(&*blobs),
1995            db,
1996        };
1997        ocserve_store::fork_session(
1998            &env,
1999            &fork_info,
2000            &fork_id,
2001            fork_upto.as_deref(),
2002            ocserve_core::ids::msg_id,
2003            ocserve_core::ids::prt_id,
2004        )
2005    })
2006    .await
2007    .map_err(|e| ApiError {
2008        status: StatusCode::INTERNAL_SERVER_ERROR,
2009        name: "InternalError",
2010        message: format!("{e:#}"),
2011    })?;
2012    ocserve_metrics::counter("ocserve_session_fork_total", 1);
2013    tracing::info!(
2014        fork = %new_id,
2015        source = %id,
2016        upto = ?upto,
2017        messages = stats.messages,
2018        parts = stats.parts,
2019        "session forked"
2020    );
2021    // session.created AFTER the copy (D-FORK-1): upstream publishes at
2022    // createNext BEFORE cloning — a client reacting to the event can read a
2023    // partial session; ours fires when the history is complete. Every known
2024    // consumer (dialog-fork, ACP, oc-remote) REST-loads after this response.
2025    st.bus.publish(ocserve_core::event::frame(
2026        st.paths["directory"].as_str().unwrap_or("/"),
2027        "session.created",
2028        json!({"sessionID": new_id, "info": info}),
2029    ));
2030    Ok(Json(info))
2031}
2032
2033/// GET /session/{id}/message — [{info, parts}] (captured wrapper shape).
2034async fn get_messages(
2035    State(st): State<Arc<AppState>>,
2036    axum::extract::Path(id): axum::extract::Path<String>,
2037    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
2038    uri: axum::http::Uri,
2039    headers: axum::http::HeaderMap,
2040) -> Result<impl IntoResponse, HttpError> {
2041    // Freeze rule order (handlers/session.ts:110-117 + observed matrix in
2042    // message_page_contract.json): query-shape errors FIRST (Effect decodes
2043    // the whole query before the handler), then before-rules, then session.
2044    let limit: Option<u64> = match q.get("limit") {
2045        None => None,
2046        Some(raw) if raw.is_empty() => None, // `limit=` observed as full history
2047        Some(raw) => Some(parse_limit(raw).map_err(|message| HttpError::Query { message })?),
2048    };
2049    let before: Option<(String, i64)> = match (q.get("before").map(String::as_str), limit) {
2050        (Some(_), None) => return Err(tagged_bad_request()),
2051        (Some(b), Some(_)) => {
2052            Some(ocserve_store::decode_cursor(b).map_err(|_| tagged_bad_request())?)
2053        }
2054        (None, _) => None,
2055    };
2056    let exists_id = id.clone();
2057    if !run_blocking(&st.db, move |db| {
2058        ocserve_store::session_exists(db, &exists_id)
2059    })
2060    .await
2061    .map_err(|e| ApiError {
2062        status: StatusCode::INTERNAL_SERVER_ERROR,
2063        name: "InternalError",
2064        message: format!("{e:#}"),
2065    })? {
2066        return Err(ApiError::not_found(format!("Session not found: {id}")).into());
2067    }
2068    // limit>0 → tuple-ordered cursor page (upstream MessageV2.page); absent/0
2069    // → full history. Headers ONLY when older messages remain (their rule).
2070    let mut page_n: Option<u64> = None;
2071    let mut page_next: Option<String> = None;
2072    let walk: ocserve_store::MessageWalk = match limit.filter(|n| *n > 0) {
2073        Some(n) => {
2074            let page_id = id.clone();
2075            let before_owned = before.clone();
2076            let (rows, _more, next) = run_blocking(&st.db, move |db| {
2077                ocserve_store::page_messages(
2078                    db,
2079                    &page_id,
2080                    n,
2081                    before_owned.as_ref().map(|(b, t)| (b.as_str(), *t)),
2082                )
2083            })
2084            .await
2085            .map_err(|e| ApiError {
2086                status: StatusCode::INTERNAL_SERVER_ERROR,
2087                name: "InternalError",
2088                message: format!("{e:#}"),
2089            })?;
2090            page_n = Some(n);
2091            page_next = next;
2092            ocserve_store::MessageWalk::Window(rows)
2093        }
2094        None => ocserve_store::MessageWalk::Seq { limit: None },
2095    };
2096    // F9: memoized paged body. Key carries db path (cross-test/process
2097    // isolation: same session id in another tmpdir must not collide) +
2098    // limit + cursor + the live write epoch (checked inside get).
2099    if page_n.is_some() && page_memo_enabled() {
2100        let memo_key = format!("{}\u{1}{id}\u{1}{limit:?}\u{1}{before:?}", st.db.display());
2101        let e0 = ocserve_store::write_epoch();
2102        {
2103            let mut m = page_memo().lock();
2104            if let Some(e) = m.get(&memo_key, e0) {
2105                drop(m);
2106                PAGE_MEMO_HITS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2107                return page_response(
2108                    &headers,
2109                    &uri,
2110                    &page_n,
2111                    &page_next,
2112                    axum::body::Body::from(e.body),
2113                )
2114                .map_err(Into::into);
2115            }
2116        }
2117        PAGE_MEMO_MISSES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2118        // assemble the page bounded — oversize aborts the collect and we
2119        // fall through to the original streaming path (walk not consumed:
2120        // the assembler gets a clone).
2121        let walk_asm = walk.clone();
2122        let db_asm = st.db.clone();
2123        let sid_asm = id.clone();
2124        let asm: anyhow::Result<std::result::Result<Vec<u8>, ()>> =
2125            run_blocking(&db_asm, move |db| {
2126                let mut buf: Vec<u8> = Vec::new();
2127                let mut total = 0usize;
2128                let mut first = true;
2129                let r = ocserve_store::for_each_message_json(db, &sid_asm, walk_asm, |chunk| {
2130                    let framed = if first {
2131                        first = false;
2132                        format!("[{chunk}")
2133                    } else {
2134                        format!(",{chunk}")
2135                    };
2136                    if total + framed.len() > PAGE_MEMO_ENTRY_CAP {
2137                        return Err(anyhow::anyhow!("page memo entry cap"));
2138                    }
2139                    total += framed.len();
2140                    buf.extend_from_slice(framed.as_bytes());
2141                    Ok(())
2142                });
2143                match r {
2144                    Ok(()) => {
2145                        // mirror the streaming close exactly: zero chunks
2146                        // => "[]" (a bare "]" would not be valid JSON)
2147                        if first {
2148                            buf.extend_from_slice(b"[]");
2149                        } else {
2150                            buf.push(b']');
2151                        }
2152                        Ok(Ok(buf))
2153                    }
2154                    Err(e) if e.to_string().contains("entry cap") => Ok(Err(())),
2155                    Err(e) => Err(e),
2156                }
2157            })
2158            .await;
2159        if let Ok(Ok(buf)) = asm {
2160            let bytes = bytes::Bytes::from(buf);
2161            let entry = PageEntry {
2162                body: bytes.clone(),
2163                next: page_next.clone(),
2164            };
2165            page_memo().lock().put(memo_key, entry, e0);
2166            return page_response(
2167                &headers,
2168                &uri,
2169                &page_n,
2170                &page_next,
2171                axum::body::Body::from(bytes),
2172            )
2173            .map_err(Into::into);
2174        }
2175        // oversize or assemble error → original streaming path below
2176    }
2177    // STREAMED response: one message per chunk through a bounded channel —
2178    // materializing a 16k-message session as Values OOM-killed the cgroup
2179    // (93MB response ≈ 400MB+ parsed; AGENTS §2.3 violation caught live).
2180    // Peak = channel(4) chunks + the message being built.
2181    let (tx, rx) = tokio::sync::mpsc::channel::<String>(4);
2182    let db = st.db.clone();
2183    let sid = id.clone();
2184    // F4: the tokio blocking pool, not a per-request OS thread — every
2185    // message_page request used to spawn a fresh thread (spawn+1MB VA per
2186    // page under load). The producer only does sync store work + channel
2187    // sends; dropping the JoinHandle keeps it detached exactly like before,
2188    // and the pool caps concurrent producers (bounded-by-construction).
2189    tokio::task::spawn_blocking(move || {
2190        let mut first = true;
2191        let r = ocserve_store::for_each_message_json(&db, &sid, walk, |chunk| {
2192            let framed = if first {
2193                first = false;
2194                format!("[{chunk}")
2195            } else {
2196                format!(",{chunk}")
2197            };
2198            tx.blocking_send(framed)
2199                .map_err(|_| anyhow::anyhow!("client disconnected"))
2200        });
2201        if let Err(e) = r {
2202            tracing::error!("message stream aborted session={sid}: {e:#}");
2203        }
2204        // best-effort close bracket (valid JSON even after a mid-stream error)
2205        let _ = tx.blocking_send(if first {
2206            "[]".to_string()
2207        } else {
2208            "]".to_string()
2209        });
2210    });
2211    let body_stream = futures_util::stream::unfold(rx, |mut rx| async move {
2212        rx.recv().await.map(|chunk| {
2213            (
2214                Ok::<axum::body::Bytes, std::convert::Infallible>(axum::body::Bytes::from(chunk)),
2215                rx,
2216            )
2217        })
2218    });
2219    page_response(
2220        &headers,
2221        &uri,
2222        &page_n,
2223        &page_next,
2224        axum::body::Body::from_stream(body_stream),
2225    )
2226    .map_err(Into::into)
2227}
2228
2229/// PATCH /config + PATCH /global/config — W4 merge (v1 ConfigHttpApi.update:
2230/// deep-merges the payload, marks the instance for disposal, returns the
2231/// ECHOED payload — handlers/config.ts:18-21). Atomic file write (tmp in
2232/// the same dir → rename, .bak kept), then the boot-injected reloader
2233/// swaps derived payloads so the next GET serves fresh config. Divergence:
2234/// ocserve treats global==user config (single file); runtime endpoint
2235/// registries refresh on restart (TESTING §1.6).
2236async fn patch_config(
2237    State(st): State<Arc<AppState>>,
2238    Json(payload): Json<Value>,
2239) -> Result<Json<Value>, ApiError> {
2240    let path = config_write_path();
2241    let mut base: Value = if path.exists() {
2242        let raw = std::fs::read_to_string(&path).map_err(|e| ApiError {
2243            status: StatusCode::INTERNAL_SERVER_ERROR,
2244            name: "InternalError",
2245            message: format!("read {}: {e}", path.display()),
2246        })?;
2247        serde_json::from_str(&raw).map_err(|e| ApiError {
2248            status: StatusCode::INTERNAL_SERVER_ERROR,
2249            name: "InternalError",
2250            message: format!("parse {}: {e}", path.display()),
2251        })?
2252    } else {
2253        json!({})
2254    };
2255    deep_merge(&mut base, payload.clone());
2256    let dir = path.parent().ok_or_else(|| ApiError {
2257        status: StatusCode::INTERNAL_SERVER_ERROR,
2258        name: "InternalError",
2259        message: "config path has no parent".into(),
2260    })?;
2261    std::fs::create_dir_all(dir).map_err(|e| ApiError {
2262        status: StatusCode::INTERNAL_SERVER_ERROR,
2263        name: "InternalError",
2264        message: format!("mkdir {}: {e}", dir.display()),
2265    })?;
2266    let tmp = dir.join(format!(".opencode.json.tmp-{}", std::process::id()));
2267    let data = serde_json::to_vec_pretty(&base).map_err(|e| ApiError {
2268        status: StatusCode::INTERNAL_SERVER_ERROR,
2269        name: "InternalError",
2270        message: format!("serialize: {e}"),
2271    })?;
2272    std::fs::write(&tmp, &data).map_err(|e| ApiError {
2273        status: StatusCode::INTERNAL_SERVER_ERROR,
2274        name: "InternalError",
2275        message: format!("write {}: {e}", tmp.display()),
2276    })?;
2277    if path.exists() {
2278        let _ = std::fs::copy(&path, dir.join(".opencode.json.bak"));
2279    }
2280    std::fs::rename(&tmp, &path).map_err(|e| ApiError {
2281        status: StatusCode::INTERNAL_SERVER_ERROR,
2282        name: "InternalError",
2283        message: format!("rename {}: {e}", path.display()),
2284    })?;
2285    // instance disposal analog: rebuild derived payloads + registry AND
2286    // reconcile MCP (a PATCH that adds a server now connects it — H1
2287    // unifies every reload path through watch::reconcile)
2288    if let Err(e) = watch::reconcile(&st).await {
2289        tracing::warn!("config reload failed (restart to apply): {e:#}");
2290    }
2291    tracing::info!("config patched: {}", path.display());
2292    Ok(Json(payload))
2293}
2294
2295/// POST /session/{id}/summarize — W3 compaction-lite (v1 handlers/session.ts
2296/// summarize: payload {providerID, modelID, auto?} → true). Runs ONE
2297/// transient text-only turn carrying buildPrompt's instruction + serialized
2298/// history (ocserve-core::compact); the summary lands as an ordinary
2299/// assistant message (divergence: no compaction-state/history filtering —
2300/// TESTING §1.6). Synchronous like upstream (`yield* promptSvc.loop`).
2301async fn post_summarize(
2302    State(st): State<Arc<AppState>>,
2303    axum::extract::Path(sid): axum::extract::Path<String>,
2304    Json(payload): Json<Value>,
2305) -> Result<Json<Value>, ApiError> {
2306    let exists_id = sid.clone();
2307    if !run_blocking(&st.db, move |db| {
2308        ocserve_store::session_exists(db, &exists_id)
2309    })
2310    .await
2311    .map_err(|e| ApiError {
2312        status: StatusCode::INTERNAL_SERVER_ERROR,
2313        name: "InternalError",
2314        message: format!("{e:#}"),
2315    })? {
2316        return Err(ApiError::not_found(format!("Session not found: {sid}")));
2317    }
2318    let provider = payload
2319        .get("providerID")
2320        .and_then(|v| v.as_str())
2321        .filter(|s| !s.is_empty());
2322    let model_id = payload
2323        .get("modelID")
2324        .and_then(|v| v.as_str())
2325        .filter(|s| !s.is_empty());
2326    let (Some(provider), Some(model_id)) = (provider, model_id) else {
2327        return Err(ApiError {
2328            status: StatusCode::BAD_REQUEST,
2329            name: "BadRequest",
2330            message: "providerID and modelID are required".into(),
2331        });
2332    };
2333
2334    let release = lock_session(&st, &sid).await?;
2335    let Ok(_guard) = release.arc().try_lock() else {
2336        return Err(session_busy(&sid));
2337    };
2338
2339    // C5 retrofit (M6, COMPACTION.md §3): manual summarize = v1 source
2340    // (handlers/session.ts:273-293 at the FREEZE TAG v1.18.31, verified):
2341    // compactSvc.create(anchor with compaction part) + loop → pending task
2342    // → engine process → summary-exit. The run's outer machinery detects
2343    // the pending anchor — no generation runs (exit via last-message
2344    // summary check). Session-shaped history load ONLY for findLast user's
2345    // agent (v1 line 280).
2346    let history = run_blocking(&st.db, {
2347        let sid = sid.clone();
2348        move |db| ocserve_store::load_messages(db, &sid, None) // allow:load_messages (summarize compaction)
2349    })
2350    .await
2351    .map_err(|e| ApiError {
2352        status: StatusCode::INTERNAL_SERVER_ERROR,
2353        name: "InternalError",
2354        message: format!("{e:#}"),
2355    })?;
2356    let agent = history
2357        .iter()
2358        .rev()
2359        .find(|(info, _)| info["role"] == "user")
2360        .and_then(|(info, _)| info["agent"].as_str())
2361        .unwrap_or("build")
2362        .to_string();
2363    let prompt_payload = ocserve_core::wire::PromptRequest {
2364        model: Some(ocserve_core::wire::ModelRef {
2365            provider_id: provider.to_string(),
2366            model_id: model_id.to_string(),
2367        }),
2368        agent: Some(agent.clone()),
2369        ..Default::default()
2370    };
2371    let ctx = build_prompt_context(&st, &prompt_payload, &sid)?;
2372    let writer = st.writer.clone();
2373    ocserve_core::compaction::persist_anchor(&ctx, &writer, &sid, &agent, false, false)
2374        .await
2375        .map_err(|e| ApiError {
2376            status: StatusCode::INTERNAL_SERVER_ERROR,
2377            name: "InternalError",
2378            message: format!("{e:#}"),
2379        })?;
2380    ocserve_core::prompt::run_prompt_with(
2381        &ctx,
2382        &writer,
2383        &sid,
2384        &prompt_payload,
2385        ocserve_core::prompt::RunOpts {
2386            // the ANCHOR is already the newest message — nothing from the
2387            // payload persists; pending→engine→summary-exit short-circuits
2388            // before any generation (persist_user=false keeps chat.message
2389            // silent, P0c invariant)
2390            persist_user: false,
2391            skip_history: false,
2392            prelude: None,
2393            tools_enabled: false,
2394            ..Default::default()
2395        },
2396    )
2397    .await
2398    .map_err(prompt_err)?;
2399    // P0e: session.compacted (magic-context consumes it after a summary)
2400    st.bus.publish(ocserve_core::event::frame(
2401        st.paths["directory"].as_str().unwrap_or("/"),
2402        "session.compacted",
2403        json!({"sessionID": sid}),
2404    ));
2405    Ok(Json(json!(true)))
2406}
2407
2408/// Shared compaction trigger used by both the v1 summarize route and the v2
2409/// `/api/session/{id}/compact` route (single owner of the summarize flow).
2410pub(crate) async fn run_compact(
2411    st: &Arc<AppState>,
2412    ctx: &ocserve_core::prompt::PromptContext,
2413    writer: &ocserve_store::Writer,
2414    sid: &str,
2415) -> anyhow::Result<()> {
2416    let agent = ctx.agent.clone();
2417    let prompt_payload = ocserve_core::wire::PromptRequest {
2418        model: Some(ocserve_core::wire::ModelRef {
2419            provider_id: ctx.provider_id.clone(),
2420            model_id: ctx.model_id.clone(),
2421        }),
2422        agent: Some(agent.clone()),
2423        ..Default::default()
2424    };
2425    ocserve_core::compaction::persist_anchor(ctx, writer, sid, &agent, false, false).await?;
2426    ocserve_core::prompt::run_prompt_with(
2427        ctx,
2428        writer,
2429        sid,
2430        &prompt_payload,
2431        ocserve_core::prompt::RunOpts {
2432            persist_user: false,
2433            skip_history: false,
2434            prelude: None,
2435            tools_enabled: false,
2436            ..Default::default()
2437        },
2438    )
2439    .await?;
2440    st.bus.publish(ocserve_core::event::frame(
2441        st.paths["directory"].as_str().unwrap_or("/"),
2442        "session.compacted",
2443        json!({"sessionID": sid}),
2444    ));
2445    Ok(())
2446}
2447
2448/// W5 admin bundle — POST /mcp/{name}/connect|disconnect (the client's
2449/// generic `/$action` path is identical to v1 McpPaths.connect/disconnect),
2450/// DELETE /mcp/{name}/auth, PUT/DELETE /auth/{providerID} (ocserve-OWN auth
2451/// overlay — NEVER the legacy auth.json ocserve reads), GET /provider/auth,
2452/// POST /global/dispose.
2453fn mcp_not_found(name: &str) -> HttpError {
2454    HttpError::TaggedData {
2455        status: StatusCode::NOT_FOUND,
2456        tag: "McpServerNotFoundError",
2457        fields: json!({"name": name, "message": format!("MCP server not found: {name}")}),
2458    }
2459}
2460
2461async fn mcp_action(
2462    State(st): State<Arc<AppState>>,
2463    axum::extract::Path((name, action)): axum::extract::Path<(String, String)>,
2464) -> Result<Json<Value>, HttpError> {
2465    let hub = st.mcp.get().cloned().ok_or_else(|| HttpError::TaggedData {
2466        status: StatusCode::SERVICE_UNAVAILABLE,
2467        tag: "McpUnavailable",
2468        fields: json!({"message": "MCP hub not initialized"}),
2469    })?;
2470    let res = match action.as_str() {
2471        "connect" => hub.connect(&name).await,
2472        "disconnect" => hub.disconnect(&name).await,
2473        _ => Err(anyhow::anyhow!("unknown MCP action: {action}")),
2474    };
2475    res.map_err(|e| {
2476        if e.to_string().contains("not found") {
2477            mcp_not_found(&name)
2478        } else {
2479            HttpError::Api(ApiError {
2480                status: StatusCode::INTERNAL_SERVER_ERROR,
2481                name: "InternalError",
2482                message: format!("{e:#}"),
2483            })
2484        }
2485    })?;
2486    Ok(Json(json!(true)))
2487}
2488
2489async fn mcp_auth_remove(
2490    State(st): State<Arc<AppState>>,
2491    axum::extract::Path(name): axum::extract::Path<String>,
2492) -> Result<Json<Value>, HttpError> {
2493    // v1 authRemove: existence check first, then removeAuth → {success:true}
2494    let known = st
2495        .mcp
2496        .get()
2497        .map(|h| {
2498            let m = h.statuses();
2499            m.as_object()
2500                .map(|o| o.contains_key(&name))
2501                .unwrap_or(false)
2502                || h.known(&name)
2503        })
2504        .unwrap_or(false);
2505    if !known {
2506        return Err(mcp_not_found(&name));
2507    }
2508    // no OAuth MCP servers are configured → removing stored auth is a no-op
2509    // success (divergence TESTING §1.6)
2510    Ok(Json(json!({"success": true})))
2511}
2512
2513/// ocserve-owned auth overlay — the file ocserve WRITES; the legacy auth.json
2514/// ocserve READS stays untouched (a bad write there would brick the TUI).
2515fn auth_overlay_path(st: &AppState) -> std::path::PathBuf {
2516    st.db
2517        .parent()
2518        .unwrap_or(std::path::Path::new("."))
2519        .join("auth-overlay.json")
2520}
2521
2522fn write_json_atomic(path: &std::path::Path, value: &Value) -> Result<(), ApiError> {
2523    let dir = path.parent().ok_or_else(|| ApiError {
2524        status: StatusCode::INTERNAL_SERVER_ERROR,
2525        name: "InternalError",
2526        message: "path has no parent".into(),
2527    })?;
2528    std::fs::create_dir_all(dir).map_err(|e| ApiError {
2529        status: StatusCode::INTERNAL_SERVER_ERROR,
2530        name: "InternalError",
2531        message: format!("mkdir: {e}"),
2532    })?;
2533    let tmp = dir.join(format!(".tmp-{}-auth", std::process::id()));
2534    let data = serde_json::to_vec_pretty(value).map_err(|e| ApiError {
2535        status: StatusCode::INTERNAL_SERVER_ERROR,
2536        name: "InternalError",
2537        message: format!("serialize: {e}"),
2538    })?;
2539    std::fs::write(&tmp, data).map_err(|e| ApiError {
2540        status: StatusCode::INTERNAL_SERVER_ERROR,
2541        name: "InternalError",
2542        message: format!("write: {e}"),
2543    })?;
2544    std::fs::rename(&tmp, path).map_err(|e| ApiError {
2545        status: StatusCode::INTERNAL_SERVER_ERROR,
2546        name: "InternalError",
2547        message: format!("rename: {e}"),
2548    })?;
2549    Ok(())
2550}
2551
2552async fn auth_put(
2553    State(st): State<Arc<AppState>>,
2554    axum::extract::Path(pid): axum::extract::Path<String>,
2555    Json(body): Json<Value>,
2556) -> Result<Json<Value>, ApiError> {
2557    if !body.is_object() {
2558        return Err(ApiError {
2559            status: StatusCode::BAD_REQUEST,
2560            name: "BadRequest",
2561            message: "auth payload must be an object".into(),
2562        });
2563    }
2564    let path = auth_overlay_path(&st);
2565    let mut overlay: Value = if path.exists() {
2566        std::fs::read_to_string(&path)
2567            .ok()
2568            .and_then(|r| serde_json::from_str(&r).ok())
2569            .unwrap_or_else(|| json!({}))
2570    } else {
2571        json!({})
2572    };
2573    overlay[&pid] = body; // Auth.Info union stored verbatim (discriminator "type")
2574    write_json_atomic(&path, &overlay)?;
2575    if let Err(e) = watch::reconcile(&st).await {
2576        tracing::warn!("payload reload failed: {e:#}");
2577    }
2578    tracing::info!("auth overlay set provider={pid}");
2579    Ok(Json(json!(true)))
2580}
2581
2582async fn auth_delete(
2583    State(st): State<Arc<AppState>>,
2584    axum::extract::Path(pid): axum::extract::Path<String>,
2585) -> Result<Json<Value>, ApiError> {
2586    let path = auth_overlay_path(&st);
2587    if path.exists() {
2588        let mut overlay: Value = std::fs::read_to_string(&path)
2589            .ok()
2590            .and_then(|r| serde_json::from_str(&r).ok())
2591            .unwrap_or_else(|| json!({}));
2592        if let Some(obj) = overlay.as_object_mut() {
2593            obj.remove(&pid);
2594        }
2595        write_json_atomic(&path, &overlay)?;
2596        if let Err(e) = watch::reconcile(&st).await {
2597            tracing::warn!("payload reload failed: {e:#}");
2598        }
2599    }
2600    tracing::info!("auth overlay remove provider={pid}");
2601    Ok(Json(json!(true)))
2602}
2603
2604/// GET /provider/auth — {providerID: [{type,label}]}. v1 derives these from
2605/// auth HOOKS (provider/auth.ts:131); ocserve serves what ocserve can do —
2606/// api-key methods for every configured provider (divergence TESTING §1.6;
2607/// matches the PUT /auth {type:"api"} client flow).
2608async fn provider_auth_methods(State(st): State<Arc<AppState>>) -> Json<Value> {
2609    let payloads = st.payloads.read();
2610    let mut out = serde_json::Map::new();
2611    // LIVE shape: config_providers.providers is a LIST of {id, ...} — the
2612    // dict assumption returned {} (caught on the W5 live battery)
2613    if let Some(list) = payloads
2614        .config_providers
2615        .get("providers")
2616        .and_then(|p| p.as_array())
2617    {
2618        for e in list {
2619            if let Some(pid) = e.get("id").and_then(|v| v.as_str()) {
2620                out.insert(
2621                    pid.to_string(),
2622                    json!([{"type": "api", "label": "API Key"}]),
2623                );
2624            }
2625        }
2626    }
2627    Json(serde_json::Value::Object(out))
2628}
2629
2630/// POST /global/dispose — v1 (global.ts:84): dispose + emit + true. Ocserve
2631/// analog: durable+live `server.instance.disposed` (EventReducer clears
2632/// client state) + payload reload. Divergence: no instance registry
2633/// (TESTING §1.6).
2634async fn global_dispose(State(st): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
2635    // spec Event.server.instance.disposed.properties: {directory} (required).
2636    let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
2637    let props = json!({"directory": dir});
2638    ocserve_store::append_event(&st.writer, None, "server.instance.disposed", &props).map_err(
2639        |e| ApiError {
2640            status: StatusCode::INTERNAL_SERVER_ERROR,
2641            name: "InternalError",
2642            message: format!("{e:#}"),
2643        },
2644    )?;
2645    st.bus.publish(ocserve_core::event::frame(
2646        &dir,
2647        "server.instance.disposed",
2648        props,
2649    ));
2650    if let Err(e) = watch::reconcile(&st).await {
2651        tracing::warn!("payload reload failed: {e:#}");
2652    }
2653    tracing::info!("global dispose: emitted + payloads reloaded");
2654    Ok(Json(json!(true)))
2655}
2656
2657/// POST /session/{id}/revert — real file-restoring revert (SNAPSHOT.md §2,
2658/// upstream session/revert.ts): snapshot the worktree if needed, restore the
2659/// pre-turn files, revert the tool `patch` parts, compute the revert-range
2660/// diff, persist per-message `summary.diffs` + session `summary`, emit
2661/// `session.diff`, set the marker. Busy → 409 (mapBusy parity).
2662async fn post_revert(
2663    State(st): State<Arc<AppState>>,
2664    axum::extract::Path(id): axum::extract::Path<String>,
2665    Json(payload): Json<Value>,
2666) -> Result<axum::response::Response, ApiError> {
2667    let exists_id = id.clone();
2668    if !run_blocking(&st.db, move |db| {
2669        ocserve_store::session_exists(db, &exists_id)
2670    })
2671    .await
2672    .map_err(internal_err)?
2673    {
2674        return Err(ApiError::not_found(format!("Session not found: {id}")));
2675    }
2676    let message_id = payload
2677        .get("messageID")
2678        .and_then(|v| v.as_str())
2679        .unwrap_or("")
2680        .to_string();
2681    if message_id.is_empty() {
2682        // freeze shape: {"name":"BadRequest","data":{"message":...,
2683        // "kind":"Payload"}} — probed 2026-10-08.
2684        return Ok((
2685            StatusCode::BAD_REQUEST,
2686            axum::Json(json!({
2687                "name": "BadRequest",
2688                "data": {"message": "Missing key\n  at [\"messageID\"]", "kind": "Payload"}
2689            })),
2690        )
2691            .into_response());
2692    }
2693    let part_id = payload
2694        .get("partID")
2695        .and_then(|v| v.as_str())
2696        .filter(|p| !p.is_empty())
2697        .map(String::from);
2698
2699    // busy → 409 (upstream mapBusy)
2700    let release = lock_session(&st, &id).await?;
2701    let Ok(_guard) = release.arc().try_lock() else {
2702        return Err(session_busy(&id));
2703    };
2704
2705    let marker = revert_apply(&st, &id, &message_id, part_id.as_deref()).await?;
2706    let _ = marker;
2707    let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
2708    let _ = dir;
2709    let sid = id.clone();
2710    run_blocking(&st.db, move |db| ocserve_store::load_session_wire(db, &sid))
2711        .await
2712        .map_err(internal_err)?
2713        .map(|j| axum::Json(j).into_response())
2714        .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))
2715}
2716
2717pub(crate) fn internal_err(e: anyhow::Error) -> ApiError {
2718    ApiError {
2719        status: StatusCode::INTERNAL_SERVER_ERROR,
2720        name: "InternalError",
2721        message: format!("{e:#}"),
2722    }
2723}
2724
2725/// Shared revert engine (v1 + v2 stage): resolve target, restore files,
2726/// compute + persist the range summary, emit `session.diff`, set the marker.
2727pub(crate) async fn revert_apply(
2728    st: &Arc<AppState>,
2729    id: &str,
2730    message_id: &str,
2731    part_id: Option<&str>,
2732) -> Result<ocserve_core::revert::Marker, ApiError> {
2733    let db = st.db.clone();
2734    let sid = id.to_string();
2735    let mid = message_id.to_string();
2736    let pid = part_id.map(String::from);
2737    // Load the full message graph once, off the workers.
2738    let messages = run_blocking(&db, move |db| {
2739        ocserve_store::load_messages(db, &sid, None) // allow:load_messages (SNAPSHOT revert: full message graph for target resolution + patch collection, upstream revert.ts)
2740    })
2741    .await
2742    .map_err(internal_err)?;
2743    let target = ocserve_core::revert::resolve_target(&messages, &mid, pid.as_deref());
2744    let dir = session_directory_http(st, id).await;
2745    let snapshots = snapshots_for(st, &dir);
2746
2747    // marker.snapshot = existing snapshot else capture now (upstream `?? track()`)
2748    let existing = run_blocking(&st.db, {
2749        let sid = id.to_string();
2750        move |db| ocserve_store::load_session_wire(db, &sid)
2751    })
2752    .await
2753    .map_err(internal_err)?
2754    .and_then(|s| {
2755        s.get("revert")
2756            .and_then(|r| r.get("snapshot"))
2757            .and_then(|v| v.as_str())
2758            .map(String::from)
2759    });
2760
2761    let mut marker = ocserve_core::revert::Marker {
2762        message_id: target
2763            .as_ref()
2764            .map(|t| t.0.clone())
2765            .unwrap_or_else(|| mid.clone()),
2766        part_id: target.as_ref().and_then(|t| t.1.clone()),
2767        snapshot: existing.clone(),
2768        diff: None,
2769    };
2770    if let Some(snap) = snapshots.clone() {
2771        let prev = marker.snapshot.clone();
2772        let (snapshot, _patches) = run_blocking(&st.db, {
2773            let msgs = messages.clone();
2774            let snap = snap.clone();
2775            let prev = prev.clone();
2776            move |_db| {
2777                let snapshot = match &prev {
2778                    Some(s) => s.clone(),
2779                    None => snap.track().unwrap_or_default(),
2780                };
2781                if let Some(p) = &prev {
2782                    snap.restore(p);
2783                }
2784                let patches = ocserve_core::revert::collect_patches(&msgs);
2785                snap.revert(&patches);
2786                Ok::<_, anyhow::Error>((snapshot, patches))
2787            }
2788        })
2789        .await
2790        .map_err(internal_err)?;
2791        if !snapshot.is_empty() {
2792            marker.snapshot = Some(snapshot.clone());
2793            let diff = run_blocking(&st.db, {
2794                let snap = snap.clone();
2795                let s = snapshot.clone();
2796                move |_db| Ok::<_, anyhow::Error>(snap.diff(&s))
2797            })
2798            .await
2799            .map_err(internal_err)?;
2800            if !diff.is_empty() {
2801                marker.diff = Some(diff);
2802            }
2803        }
2804    }
2805
2806    // revert-range diffs (summary.computeDiff over messages from the target)
2807    let range: Vec<(Value, Vec<Value>)> = match &target {
2808        Some((tmid, _)) => messages
2809            .iter()
2810            .skip_while(|(info, _)| info["id"].as_str() != Some(tmid.as_str()))
2811            .cloned()
2812            .collect(),
2813        None => Vec::new(),
2814    };
2815    let (diffs, summary) = match snapshots.clone() {
2816        Some(snap) => run_blocking(&st.db, move |_db| {
2817            Ok::<_, anyhow::Error>(ocserve_core::revert::compute_range(&snap, &range))
2818        })
2819        .await
2820        .map_err(internal_err)?,
2821        None => (
2822            Vec::new(),
2823            json!({"additions": 0, "deletions": 0, "files": 0}),
2824        ),
2825    };
2826
2827    // persist: session summary + per-user-message summary.diffs
2828    let adds = summary["additions"].as_i64().unwrap_or(0);
2829    let dels = summary["deletions"].as_i64().unwrap_or(0);
2830    let files = summary["files"].as_i64().unwrap_or(0);
2831    ocserve_store::set_session_summary(&st.writer, id, adds, dels, files).map_err(internal_err)?;
2832    if let Some((tmid, _)) = &target
2833        && let Some((info, _)) = messages
2834            .iter()
2835            .find(|(i, _)| i["id"].as_str() == Some(tmid.as_str()))
2836    {
2837        let mut info = info.clone();
2838        info["summary"] = json!({"diffs": diffs});
2839        ocserve_store::update_message_info(&st.writer, id, tmid, &info).map_err(internal_err)?;
2840    }
2841    ocserve_store::set_session_revert(&st.writer, id, Some(&marker.to_value()))
2842        .map_err(internal_err)?;
2843
2844    // events: session.diff (upstream Session.Event.Diff), then session.updated
2845    let diff_val = Value::Array(diffs.clone());
2846    emit_durable_http(
2847        st,
2848        id,
2849        "session.diff",
2850        json!({"sessionID": id, "diff": diff_val}),
2851    )?;
2852    let info = session_wire_or_partial_http(st, id, json!({"revert": marker.to_value()})).await;
2853    emit_durable_http(
2854        st,
2855        id,
2856        "session.updated",
2857        json!({"sessionID": id, "info": info}),
2858    )?;
2859    Ok(marker)
2860}
2861
2862/// POST /session/{id}/unrevert — restore the snapshot + clear the marker.
2863async fn post_unrevert(
2864    State(st): State<Arc<AppState>>,
2865    axum::extract::Path(id): axum::extract::Path<String>,
2866) -> Result<Json<Value>, ApiError> {
2867    let exists_id = id.clone();
2868    if !run_blocking(&st.db, move |db| {
2869        ocserve_store::session_exists(db, &exists_id)
2870    })
2871    .await
2872    .map_err(internal_err)?
2873    {
2874        return Err(ApiError::not_found(format!("Session not found: {id}")));
2875    }
2876    let release = lock_session(&st, &id).await?;
2877    let Ok(_guard) = release.arc().try_lock() else {
2878        return Err(session_busy(&id));
2879    };
2880    // restore the marker's snapshot (upstream unrevert)
2881    let existing = run_blocking(&st.db, {
2882        let sid = id.to_string();
2883        move |db| ocserve_store::load_session_wire(db, &sid)
2884    })
2885    .await
2886    .map_err(internal_err)?
2887    .and_then(|s| {
2888        s.get("revert")
2889            .and_then(|r| r.get("snapshot"))
2890            .and_then(|v| v.as_str())
2891            .map(String::from)
2892    });
2893    let dir = session_directory_http(&st, &id).await;
2894    if let Some(snap) = snapshots_for(&st, &dir)
2895        && let Some(s) = existing
2896    {
2897        let _ = run_blocking(&st.db, move |_db| {
2898            snap.restore(&s);
2899            Ok::<_, anyhow::Error>(())
2900        })
2901        .await;
2902    }
2903    ocserve_store::set_session_revert(&st.writer, &id, None).map_err(internal_err)?;
2904    let info = session_wire_or_partial_http(&st, &id, json!({"revert": Value::Null})).await;
2905    emit_durable_http(
2906        &st,
2907        &id,
2908        "session.updated",
2909        json!({"sessionID": id, "info": info}),
2910    )?;
2911    let sid = id.clone();
2912    run_blocking(&st.db, move |db| ocserve_store::load_session_wire(db, &sid))
2913        .await
2914        .map_err(internal_err)?
2915        .map(Json)
2916        .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))
2917}
2918
2919/// The session's stored working directory (or the server worktree fallback).
2920pub(crate) async fn session_directory_http(st: &Arc<AppState>, id: &str) -> String {
2921    let sid = id.to_string();
2922    run_blocking(&st.db, move |db| {
2923        Ok::<_, anyhow::Error>(ocserve_store::session_directory(db, &sid).ok().flatten())
2924    })
2925    .await
2926    .ok()
2927    .flatten()
2928    .unwrap_or_else(|| st.paths["directory"].as_str().unwrap_or("/").to_string())
2929}
2930
2931/// POST /instance/dispose — freeze returns `true` (marks the instance for
2932/// disposal; a single-instance server's equivalent is publishing
2933/// server.instance.disposed like /global/dispose).
2934async fn instance_dispose(State(st): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
2935    let _disposed = global_dispose(State(st)).await?;
2936    Ok(Json(json!(true)))
2937}
2938
2939/// POST /session/{id}/init — runs the built-in `init` command
2940/// (command/index.ts Default.INIT, template initialize.txt) with the
2941/// caller's model. Freeze payload {messageID, providerID, modelID}; missing
2942/// model fields → 400 Effect Payload envelope ("Missing key at [modelID]").
2943async fn post_init(
2944    State(st): State<Arc<AppState>>,
2945    axum::extract::Path(id): axum::extract::Path<String>,
2946    Json(payload): Json<Value>,
2947) -> Result<axum::response::Response, HttpError> {
2948    let provider = payload.get("providerID").and_then(|v| v.as_str());
2949    let model = payload.get("modelID").and_then(|v| v.as_str());
2950    let (Some(provider), Some(model)) = (provider, model) else {
2951        let missing = if provider.is_none() {
2952            "providerID"
2953        } else {
2954            "modelID"
2955        };
2956        return Ok((
2957            StatusCode::BAD_REQUEST,
2958            axum::Json(json!({
2959                "name": "BadRequest",
2960                "data": {
2961                    "message": format!("Missing key\n  at [\"{missing}\"]"),
2962                    "kind": "Payload"
2963                }
2964            })),
2965        )
2966            .into_response());
2967    };
2968    let exists_id = id.clone();
2969    if !run_blocking(&st.db, move |db| {
2970        ocserve_store::session_exists(db, &exists_id)
2971    })
2972    .await
2973    .map_err(|e| ApiError {
2974        status: StatusCode::INTERNAL_SERVER_ERROR,
2975        name: "InternalError",
2976        message: format!("{e:#}"),
2977    })? {
2978        return Err(ApiError::not_found(format!("Session not found: {id}")).into());
2979    }
2980    // Reuse the /command path: command=init, arguments="", model from payload.
2981    let cmd_payload = json!({
2982        "command": "init",
2983        "arguments": "",
2984        "model": format!("{provider}/{model}"),
2985        "messageID": payload.get("messageID").cloned().unwrap_or(Value::Null),
2986    });
2987    post_command(State(st), axum::extract::Path(id), Json(cmd_payload))
2988        .await
2989        .map(|j| j.into_response())
2990}
2991
2992/// POST /provider/{id}/oauth/authorize — ocserve has no OAuth flows; the
2993/// freeze shape for a non-OAuth provider is 400 {"name":"BadRequest",
2994/// "data":{}} (probed). OAuth login remains a named deferral (PLAN §17).
2995async fn post_provider_oauth(
2996    axum::extract::Path(_id): axum::extract::Path<String>,
2997) -> axum::response::Response {
2998    (
2999        StatusCode::BAD_REQUEST,
3000        axum::Json(json!({"name": "BadRequest", "data": {}})),
3001    )
3002        .into_response()
3003}
3004
3005/// POST /provider/{id}/oauth/callback — same divergence as authorize.
3006async fn post_provider_oauth_callback(
3007    axum::extract::Path(_id): axum::extract::Path<String>,
3008) -> axum::response::Response {
3009    (
3010        StatusCode::BAD_REQUEST,
3011        axum::Json(json!({"name": "BadRequest", "data": {}})),
3012    )
3013        .into_response()
3014}
3015
3016/// POST /mcp/{name}/auth/authenticate — unknown → 404 tagged; known (all
3017/// non-OAuth in practice) → 400 McpUnsupportedOAuthError (probe shapes).
3018async fn post_mcp_auth_authenticate(
3019    State(st): State<Arc<AppState>>,
3020    axum::extract::Path(name): axum::extract::Path<String>,
3021) -> Result<axum::response::Response, HttpError> {
3022    mcp_auth_gate(&st, &name)?;
3023    Err(HttpError::TaggedData {
3024        status: StatusCode::BAD_REQUEST,
3025        tag: "McpUnsupportedOAuthError",
3026        fields: json!({"error": format!("MCP server {name} does not support OAuth")}),
3027    })
3028}
3029
3030/// POST /mcp/{name}/auth/callback — same gate as authenticate.
3031async fn post_mcp_auth_callback(
3032    State(st): State<Arc<AppState>>,
3033    axum::extract::Path(name): axum::extract::Path<String>,
3034) -> Result<axum::response::Response, HttpError> {
3035    mcp_auth_gate(&st, &name)?;
3036    Err(HttpError::TaggedData {
3037        status: StatusCode::BAD_REQUEST,
3038        tag: "McpUnsupportedOAuthError",
3039        fields: json!({"error": format!("MCP server {name} does not support OAuth")}),
3040    })
3041}
3042
3043/// Shared existence check: unknown server → tagged 404 (probed shape).
3044fn mcp_auth_gate(st: &Arc<AppState>, name: &str) -> Result<(), HttpError> {
3045    let known = st
3046        .mcp
3047        .get()
3048        .map(|h| h.statuses().get(name).is_some())
3049        .unwrap_or(false);
3050    if known {
3051        return Ok(());
3052    }
3053    Err(HttpError::TaggedData {
3054        status: StatusCode::NOT_FOUND,
3055        tag: "McpServerNotFoundError",
3056        fields: json!({
3057            "name": name,
3058            "message": format!("MCP server not found: {name}"),
3059        }),
3060    })
3061}
3062
3063/// GET /session/{id}/message/{mid} → {info, parts}; unknown → 404
3064/// NotFoundError envelope (probed freeze: {"name":"NotFoundError",
3065/// "data":{"message":"Message not found: <mid>"}}).
3066async fn get_message_by_id(
3067    State(st): State<Arc<AppState>>,
3068    axum::extract::Path((id, mid)): axum::extract::Path<(String, String)>,
3069) -> Result<axum::response::Response, ApiError> {
3070    let db = st.db.clone();
3071    let sid = id.clone();
3072    let mid_c = mid.clone();
3073    let found = run_blocking(&db, move |db| {
3074        ocserve_store::message_by_id(db, &sid, &mid_c)
3075    })
3076    .await
3077    .map_err(|e| ApiError {
3078        status: StatusCode::INTERNAL_SERVER_ERROR,
3079        name: "InternalError",
3080        message: format!("{e:#}"),
3081    })?;
3082    match found {
3083        Some(v) => Ok(axum::Json(v).into_response()),
3084        None => Err(ApiError::not_found(format!("Message not found: {mid}"))),
3085    }
3086}
3087
3088/// POST /session/{id}/permissions/{pid} — deprecated v1 respond route; maps
3089/// onto the same gate as /permission/{id}/reply. Body {response: once|always|
3090/// reject}; unknown id → tagged PermissionNotFoundError (probed).
3091async fn post_session_permission_respond(
3092    State(st): State<Arc<AppState>>,
3093    axum::extract::Path((_id, pid)): axum::extract::Path<(String, String)>,
3094    Json(body): Json<Value>,
3095) -> Result<axum::response::Response, HttpError> {
3096    let reply = validate_permission_reply_key(&body, "response")?;
3097    if !st.gate.reply(&pid, reply) {
3098        return Err(HttpError::TaggedData {
3099            status: StatusCode::NOT_FOUND,
3100            tag: "PermissionNotFoundError",
3101            fields: json!({
3102                "requestID": pid,
3103                "message": format!("Permission request not found: {pid}"),
3104            }),
3105        });
3106    }
3107    Ok(axum::Json(json!(true)).into_response())
3108}
3109
3110/// POST /log — v1 control-plane log writer; body {service,level,message,extra?}
3111/// → true (probed). Writes to the tracing target so it lands in the journal.
3112async fn post_log(Json(body): Json<Value>) -> Result<Json<Value>, HttpError> {
3113    let level = body.get("level").and_then(|v| v.as_str()).unwrap_or("info");
3114    let service = body
3115        .get("service")
3116        .and_then(|v| v.as_str())
3117        .unwrap_or("app");
3118    let message = body.get("message").and_then(|v| v.as_str()).unwrap_or("");
3119    match level {
3120        "debug" => tracing::debug!(target: "ocserve::client", service, "{message}"),
3121        "warn" => tracing::warn!(target: "ocserve::client", service, "{message}"),
3122        "error" => tracing::error!(target: "ocserve::client", service, "{message}"),
3123        _ => tracing::info!(target: "ocserve::client", service, "{message}"),
3124    }
3125    Ok(Json(json!(true)))
3126}
3127
3128/// POST /session/search — W1 content search over part payloads (contract
3129/// block PLAN §17). ≥3 chars → trigram FTS substring MATCH; shorter → LIKE
3130/// fallback (same projection, one query either way). Inline AND blobbed
3131/// parts covered (projection stores uncompressed text).
3132async fn search_messages(
3133    State(st): State<Arc<AppState>>,
3134    Json(body): Json<Value>,
3135) -> Result<Json<Value>, ApiError> {
3136    let query = body
3137        .get("query")
3138        .and_then(|v| v.as_str())
3139        .unwrap_or("")
3140        .trim()
3141        .to_string();
3142    if query.is_empty() || query.chars().count() > 512 {
3143        return Err(ApiError {
3144            status: StatusCode::BAD_REQUEST,
3145            name: "BadRequest",
3146            message: "query must be a non-empty string of at most 512 characters".into(),
3147        });
3148    }
3149    let scope = body
3150        .get("sessionID")
3151        .and_then(|v| v.as_str())
3152        .map(str::to_string);
3153    let limit = body
3154        .get("limit")
3155        .and_then(|v| v.as_u64())
3156        .unwrap_or(50)
3157        .clamp(1, 200) as u32;
3158    let offset = body.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
3159    let needle = query.clone();
3160    let (hits, truncated) = run_blocking(&st.db, move |db| {
3161        ocserve_store::search_parts(db, &needle, scope.as_deref(), limit, offset)
3162    })
3163    .await
3164    .map_err(|e| ApiError {
3165        status: StatusCode::INTERNAL_SERVER_ERROR,
3166        name: "InternalError",
3167        message: format!("{e:#}"),
3168    })?;
3169    let out = json!({
3170        "hits": hits
3171            .iter()
3172            .map(|h| json!({
3173                "sessionID": h.session_id,
3174                "messageID": h.message_id,
3175                "partID": h.part_id,
3176                "role": h.role,
3177                "time": h.time,
3178                "snippet": ocserve_store::snippet(&h.text, &query),
3179            }))
3180            .collect::<Vec<_>>(),
3181        "truncated": truncated,
3182    });
3183    Ok(Json(out))
3184}
3185
3186/// POST /session/{id}/message — sync prompt (blocks until stream completes,
3187/// returns {info, parts}; captured in testdata/m2/prompt_response.json).
3188/// v1 currentModel(sessionID): payload.model → session's stored model
3189/// (updated at prompt time) → configured default.
3190fn resolve_model(
3191    st: &Arc<AppState>,
3192    model: Option<&ocserve_core::wire::ModelRef>,
3193    session_id: &str,
3194) -> (String, String) {
3195    if let Some(m) = model {
3196        return (m.provider_id.clone(), m.model_id.clone());
3197    }
3198    if let Ok(conn) = ocserve_store::pragma::open_reader(&st.db) {
3199        let stored: Option<String> = conn
3200            .query_row(
3201                "SELECT model FROM session WHERE id = ?1",
3202                [session_id],
3203                |r| r.get(0),
3204            )
3205            .ok()
3206            .flatten();
3207        if let Some(raw) = stored
3208            && let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw)
3209        {
3210            let p = v.get("providerID").and_then(|x| x.as_str()).unwrap_or("");
3211            let m = v.get("id").and_then(|x| x.as_str()).unwrap_or("");
3212            if !p.is_empty() && !m.is_empty() {
3213                return (p.to_string(), m.to_string());
3214            }
3215        }
3216    }
3217    st.llm.read().default_model.clone()
3218}
3219
3220/// Agent permission rules (v1 wire shape → evaluator Rule list). One owner so
3221/// the prompt loop and the v2 permission oracle cannot drift.
3222pub(crate) fn agent_rules(st: &Arc<AppState>, agent: &str) -> Vec<ocserve_tools::Rule> {
3223    st.payloads
3224        .read()
3225        .agent
3226        .iter()
3227        .find(|a| a["name"].as_str() == Some(agent))
3228        .and_then(|a| a["permission"].as_array())
3229        .map(|arr| {
3230            arr.iter()
3231                .filter_map(|r| {
3232                    Some(ocserve_tools::Rule {
3233                        permission: r["permission"].as_str()?.to_string(),
3234                        pattern: r["pattern"].as_str()?.to_string(),
3235                        action: r["action"].as_str()?.to_string(),
3236                    })
3237                })
3238                .collect()
3239        })
3240        .unwrap_or_default()
3241}
3242
3243/// Skills for the system-prompt block (system.ts:skills): the command payloads
3244/// with source "skill", as (name, description, location). Built-in skills
3245/// carry an empty location; upstream writes "<built-in>" for those.
3246fn skill_entries(st: &Arc<AppState>) -> Vec<(String, String, String, String)> {
3247    let payloads = st.payloads.read();
3248    payloads
3249        .command
3250        .iter()
3251        .filter(|c| c.get("source").and_then(|v| v.as_str()) == Some("skill"))
3252        .filter_map(|c| {
3253            let name = c.get("name").and_then(|v| v.as_str())?.to_string();
3254            let description = c
3255                .get("description")
3256                .and_then(|v| v.as_str())
3257                .unwrap_or("")
3258                .to_string();
3259            let location = c
3260                .get("location")
3261                .and_then(|v| v.as_str())
3262                .filter(|s| !s.is_empty())
3263                .unwrap_or("<built-in>")
3264                .to_string();
3265            let content = c
3266                .get("template")
3267                .and_then(|v| v.as_str())
3268                .unwrap_or("")
3269                .to_string();
3270            Some((name, description, location, content))
3271        })
3272        .collect()
3273}
3274
3275/// Resolve agent/model/system/endpoint/rules into a runnable prompt context.
3276/// Shared by POST /message (sync) and POST /prompt_async (backgrounded).
3277pub(crate) fn build_prompt_context(
3278    st: &Arc<AppState>,
3279    req: &ocserve_core::wire::PromptRequest,
3280    session_id: &str,
3281) -> Result<ocserve_core::prompt::PromptContext, ApiError> {
3282    let agent = req
3283        .agent
3284        .as_deref()
3285        .filter(|a| !a.is_empty())
3286        .map(String::from)
3287        .unwrap_or_else(|| st.llm.read().default_agent.clone());
3288    let system = st
3289        .llm
3290        .read()
3291        .systems
3292        .get(&agent)
3293        .cloned()
3294        .unwrap_or_default();
3295    let (pid, mid) = resolve_model(st, req.model.as_ref(), session_id);
3296    let (base_url, api_key) =
3297        st.llm
3298            .read()
3299            .endpoints
3300            .get(&pid)
3301            .cloned()
3302            .ok_or_else(|| ApiError {
3303                status: StatusCode::BAD_REQUEST,
3304                name: "BadRequest",
3305                message: format!("no endpoint configured for provider {pid}"),
3306            })?;
3307    let pricing = st
3308        .llm
3309        .read()
3310        .pricing
3311        .get(&(pid.clone(), mid.clone()))
3312        .copied();
3313    // agent permission rules (v1 wire shape → evaluator)
3314    let rules = agent_rules(st, &agent);
3315    let model_limit = st
3316        .llm
3317        .read()
3318        .limits
3319        .get(&(pid.clone(), mid.clone()))
3320        .cloned()
3321        .unwrap_or(serde_json::Value::Null);
3322    let compaction = {
3323        let payloads = st.payloads.read();
3324        ocserve_core::compact::CompactionCfg::from_value(Some(&payloads.compaction))
3325    };
3326    let compaction_system = st
3327        .llm
3328        .read()
3329        .systems
3330        .get("compaction")
3331        .cloned()
3332        .unwrap_or_else(|| {
3333            // summarize runs on the compaction agent; if it has no explicit
3334            // prompt, fall back to the model-selected provider prompt (what
3335            // upstream would build when agent.prompt is absent).
3336            ocserve_core::system_prompt::base_prompt(None, &mid, &pid)
3337        });
3338    let session_directory = ocserve_store::session_directory(&st.db, session_id)
3339        .ok()
3340        .flatten()
3341        .unwrap_or_else(|| st.paths["directory"].as_str().unwrap_or("/").to_string());
3342    // v1 parity (K-CAPDIFF): compose the provider system message exactly as
3343    // upstream — base (agent.prompt else model-selected provider prompt) +
3344    // environment block + skills block, "\n"-joined. Before this ocserve sent
3345    // a ~250-byte blurb where freeze sends ~9.5 KB (capdiff's first find).
3346    let base = ocserve_core::system_prompt::base_prompt(
3347        if system.is_empty() {
3348            None
3349        } else {
3350            Some(&system)
3351        },
3352        &mid,
3353        &pid,
3354    );
3355    let worktree = vcs::toplevel(std::path::Path::new(&session_directory))
3356        .unwrap_or_else(|| session_directory.clone());
3357    let is_git =
3358        worktree != session_directory || std::path::Path::new(&worktree).join(".git").exists();
3359    let env_block = ocserve_core::system_prompt::environment_block(
3360        &mid,
3361        &pid,
3362        &session_directory,
3363        &worktree,
3364        is_git,
3365        std::env::consts::OS,
3366        &js_to_date_string(now_ms()),
3367    );
3368    let skills = skill_entries(st);
3369    let skill_rows: Vec<(String, String, String)> = skills
3370        .iter()
3371        .map(|(n, d, l, _)| (n.clone(), d.clone(), l.clone()))
3372        .collect();
3373    let skills_blk = ocserve_core::system_prompt::skills_block(&skill_rows);
3374    let system = ocserve_core::system_prompt::compose(&base, &[Some(env_block), skills_blk], None);
3375    let snapshots = snapshots_for(st, &session_directory);
3376    let ctx = ocserve_core::prompt::PromptContext {
3377        db: st.db.clone(),
3378        blobs: st.blobs.clone(),
3379        bus: st.bus.clone(),
3380        // The session's OWN project directory (requested at create time), not
3381        // the server cwd — scopes tool ops + external_directory checks + event
3382        // routing to the client's project (the "session thinks it's in the
3383        // ocserve repo" + constant permission-prompt class).
3384        directory: session_directory,
3385        agent: agent.clone(),
3386        system,
3387        endpoint: ocserve_core::prompt::LlmEndpoint {
3388            base_url,
3389            api_key,
3390            pricing,
3391        },
3392        model_id: mid,
3393        provider_id: pid,
3394        rules,
3395        gate: st.gate.clone(),
3396        mcp: st.mcp.get().cloned(),
3397        plugins: st.plugins.get().cloned(),
3398        questions: st.question_gate.clone(),
3399        skills,
3400        model_limit,
3401        max_rounds: st.max_rounds,
3402        cost_ceiling: st.cost_ceiling,
3403        compaction,
3404        compaction_system,
3405        // SNAPSHOT (SNAPSHOT.md): config `snapshot != false` + env kill-switch;
3406        // the engine resolves the worktree lazily and no-ops when non-git.
3407        snapshots,
3408    };
3409    Ok(ctx)
3410}
3411
3412/// Build the snapshot handle for a session directory. Enabled unless
3413/// `snapshot:false` in config (upstream key) or `OCSERVE_SNAPSHOT=0`. `None`
3414/// when disabled — the prompt then records no snapshots (upstream no-op parity).
3415fn snapshots_for(st: &AppState, directory: &str) -> Option<ocserve_core::snapshot::Snapshots> {
3416    let cfg_off = st
3417        .payloads
3418        .read()
3419        .config
3420        .get("snapshot")
3421        .map(|v| v == &serde_json::Value::Bool(false))
3422        .unwrap_or(false);
3423    let env_off = std::env::var("OCSERVE_SNAPSHOT")
3424        .map(|v| v == "0")
3425        .unwrap_or(false);
3426    if cfg_off || env_off {
3427        return None;
3428    }
3429    let data_dir = st.db.parent().unwrap_or_else(|| std::path::Path::new("."));
3430    Some(ocserve_core::snapshot::Snapshots::new(
3431        data_dir.to_path_buf(),
3432        directory,
3433        true,
3434    ))
3435}
3436
3437/// Per-session prompt lock (insert-only while active; bounded by in-flight).
3438/// JS-Number-style rendering for Effect query error messages (JS prints
3439/// ≥1e21 in exponent form WITH a plus sign: "1e+21"; Rust's Display does
3440/// not — mirror observed upstream bytes).
3441fn fmt_js(v: f64) -> String {
3442    if v.is_finite() && v.abs() >= 1e21 {
3443        let raw = format!("{v:e}"); // e.g. 1e21 / -1.5e21
3444        if let Some(pos) = raw.find('e') {
3445            return format!("{}e+{}", &raw[..pos], &raw[pos + 1..]);
3446        }
3447        raw
3448    } else {
3449        format!("{v}")
3450    }
3451}
3452
3453/// `?limit=` validation — freeze-probed matrix (message_page_contract.json):
3454/// absent/empty/0 → full history; integers (incl. "+5", "1e3") accepted up
3455/// to 2^53-1; negatives → range message; non-integers → integer message
3456/// (junk → "NaN"); mirror Effect's NumberFromString + Int + nonnegative
3457/// checks in observed order (integer-ness first, then ≥0). Rust's f64 parse
3458/// diverges from JS Number only on hex/whitespace forms — clients never
3459/// send those (documented, corpus note).
3460fn parse_limit(raw: &str) -> Result<u64, String> {
3461    const INT_MSG: &str = "Expected an integer, got {}\n  at [\"limit\"]";
3462    match raw.parse::<f64>() {
3463        Err(_) => Err(INT_MSG.replace("{}", "NaN")),
3464        Ok(v) if v.is_nan() => Err(INT_MSG.replace("{}", "NaN")),
3465        Ok(v) if v.is_infinite() => {
3466            Err(INT_MSG.replace("{}", if v > 0.0 { "Infinity" } else { "-Infinity" }))
3467        }
3468        Ok(v) if v.trunc() != v => Err(INT_MSG.replace("{}", &fmt_js(v))),
3469        Ok(v) if v.abs() > 9007199254740991.0 => Err(INT_MSG.replace("{}", &fmt_js(v))),
3470        Ok(v) if v < 0.0 => Err(format!(
3471            "Expected a value greater than or equal to 0, got {}\n  at [\"limit\"]",
3472            fmt_js(v)
3473        )),
3474        Ok(v) => Ok(v as u64),
3475    }
3476}
3477
3478fn tagged_bad_request() -> HttpError {
3479    HttpError::Tagged {
3480        status: StatusCode::BAD_REQUEST,
3481        tag: "BadRequest",
3482    }
3483}
3484
3485/// Abort-safe cleanup (antagonism A2): remove the prompt_locks entry when the
3486/// holder's task ends ANY way — normal completion, `?abort`, task panic,
3487/// delete_session — because post-run cleanup code never executes on abort
3488/// (entries only self-heal on the next prompt). Holds its own Arc clone so
3489/// the strong_count ≤ 2 check is exact during Drop. Concurrent holders or
3490/// waiters push the count above 2 → skip (entry stays — it's live).
3491struct LockRelease {
3492    locks: std::sync::Arc<
3493        parking_lot::Mutex<
3494            std::collections::HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>,
3495        >,
3496    >,
3497    sid: String,
3498    lock: std::sync::Arc<tokio::sync::Mutex<()>>,
3499}
3500
3501impl LockRelease {
3502    fn arc(&self) -> &std::sync::Arc<tokio::sync::Mutex<()>> {
3503        &self.lock
3504    }
3505}
3506
3507impl Drop for LockRelease {
3508    fn drop(&mut self) {
3509        // EXACT reference accounting (the first design had callers holding a
3510        // separate Arc clone → drop-order dependent skip, caught by the abort
3511        // test): ALL locking goes through arc() so the only refs are the map
3512        // entry + this release. remove iff count ≤ 2 (map + self).
3513        let mut map = self.locks.lock();
3514        if let Some(arc) = map.get(&self.sid)
3515            && std::sync::Arc::strong_count(arc) <= 2
3516        {
3517            map.remove(&self.sid);
3518        }
3519    }
3520}
3521
3522/// Session prompt lock + abort-safe cleanup in ONE object: callers lock via
3523/// `release.arc()` (never a second Arc clone) so Drop's count is exact, and
3524/// dropping release — normal end, `?`, panic, or task abort — evicts the map
3525/// entry. Concurrent holders/waiters each hold their own release (count > 2)
3526/// so live entries are never evicted.
3527pub(crate) async fn lock_session(st: &Arc<AppState>, sid: &str) -> Result<LockRelease, ApiError> {
3528    let lock = {
3529        let mut map = st.prompt_locks.lock();
3530        if map.len() >= 64 && !map.contains_key(sid) {
3531            return Err(ApiError {
3532                status: StatusCode::SERVICE_UNAVAILABLE,
3533                name: "ServiceUnavailableError",
3534                message: "too many concurrent prompts".into(),
3535            });
3536        }
3537        map.entry(sid.to_string())
3538            .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(())))
3539            .clone()
3540    };
3541    Ok(LockRelease {
3542        locks: std::sync::Arc::clone(&st.prompt_locks),
3543        sid: sid.to_string(),
3544        lock,
3545    })
3546}
3547
3548pub(crate) fn prompt_err(e: anyhow::Error) -> ApiError {
3549    let msg = format!("{e:#}");
3550    if msg.starts_with("Session not found") {
3551        ApiError::not_found(msg)
3552    } else {
3553        ApiError {
3554            status: StatusCode::INTERNAL_SERVER_ERROR,
3555            name: "InternalError",
3556            message: msg,
3557        }
3558    }
3559}
3560
3561async fn post_message(
3562    State(st): State<Arc<AppState>>,
3563    axum::extract::Path(id): axum::extract::Path<String>,
3564    Json(payload): Json<Value>,
3565) -> Result<impl IntoResponse, HttpError> {
3566    // wire decode FIRST (freeze: HttpApi payload decode precedes handler —
3567    // probe: `{}` on a nonexistent session → 400, valid body → 404).
3568    let req = decode_v1(ocserve_core::wire::PromptRequest::decode(&payload))?;
3569    let ctx = build_prompt_context(&st, &req, &id)?;
3570    let _release = lock_session(&st, &id).await?;
3571    let _guard = _release.arc().lock().await;
3572    let writer = st.writer.clone();
3573    let result = ocserve_core::prompt::run_prompt(&ctx, &writer, &id, &req)
3574        .await
3575        .map_err(prompt_err)?;
3576    drop(_guard);
3577    let (info, parts) = result;
3578    Ok(Json(json!({"info": info, "parts": parts})))
3579}
3580
3581/// POST /session/{id}/prompt_async — v1 `session.prompt_async` (oc-remote's
3582/// send path): same PromptPayload as /message, **204 immediately**, prompt
3583/// runs in the background; results arrive on SSE. Errors: NotFound (bad
3584/// session) / BadRequest before spawn; background failures are logged
3585/// (upstream handlers/session.ts logs "prompt_async failed", no reply body).
3586async fn post_prompt_async(
3587    State(st): State<Arc<AppState>>,
3588    axum::extract::Path(id): axum::extract::Path<String>,
3589    Json(payload): Json<Value>,
3590) -> Result<StatusCode, HttpError> {
3591    // decode FIRST (probe: `prompt_async {}` → 400 Missing key, never 204;
3592    // HttpApi payload decode precedes both 204 and the session check).
3593    let req = decode_v1(ocserve_core::wire::PromptRequest::decode(&payload))?;
3594    let exists_id = id.clone();
3595    if !run_blocking(&st.db, move |db| {
3596        ocserve_store::session_exists(db, &exists_id)
3597    })
3598    .await
3599    .map_err(|e| ApiError {
3600        status: StatusCode::INTERNAL_SERVER_ERROR,
3601        name: "InternalError",
3602        message: format!("{e:#}"),
3603    })? {
3604        return Err(ApiError::not_found(format!("Session not found: {id}")).into());
3605    }
3606    let ctx = build_prompt_context(&st, &req, &id)?;
3607    // release MOVES INTO the task: the handler returns 204 immediately, so a
3608    // handler-scoped guard would drop (and evict the lock entry) while the
3609    // prompt is still running (antagonism A2 — ownership, not just RAII).
3610    let release = lock_session(&st, &id).await?;
3611    let writer = st.writer.clone();
3612    let sid = id.clone();
3613    let generation = st
3614        .prompt_gen
3615        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3616    let tasks = st.prompt_tasks.clone();
3617    let tasks_insert = st.prompt_tasks.clone();
3618    let handle = tokio::spawn(async move {
3619        let _release = release;
3620        let guard = _release.arc().lock().await;
3621        match ocserve_core::prompt::run_prompt(&ctx, &writer, &sid, &req).await {
3622            Ok(_) => {}
3623            Err(e) => tracing::error!("prompt_async failed session={sid}: {e:#}"),
3624        }
3625        drop(guard);
3626        let mut t = tasks.lock();
3627        if let Some((g, _)) = t.get(&sid)
3628            && *g == generation
3629        {
3630            t.remove(&sid);
3631        }
3632    });
3633    tasks_insert.lock().insert(id.clone(), (generation, handle));
3634    Ok(StatusCode::NO_CONTENT)
3635}
3636
3637// ---- oc-remote contract family (Batch 1: session/message/part) ----
3638
3639async fn get_children(
3640    State(st): State<Arc<AppState>>,
3641    axum::extract::Path(id): axum::extract::Path<String>,
3642) -> Result<Json<Value>, ApiError> {
3643    let exists_id = id.clone();
3644    if !run_blocking(&st.db, move |db| {
3645        ocserve_store::session_exists(db, &exists_id)
3646    })
3647    .await
3648    .map_err(|e| ApiError {
3649        status: StatusCode::INTERNAL_SERVER_ERROR,
3650        name: "InternalError",
3651        message: format!("{e:#}"),
3652    })? {
3653        return Err(ApiError::not_found(format!("Session not found: {id}")));
3654    }
3655    run_blocking(&st.db, move |db| ocserve_store::load_children(db, &id))
3656        .await
3657        .map(|v| Json(Value::Array(v)))
3658        .map_err(|e| ApiError {
3659            status: StatusCode::INTERNAL_SERVER_ERROR,
3660            name: "InternalError",
3661            message: format!("{e:#}"),
3662        })
3663}
3664
3665async fn get_todos(
3666    State(st): State<Arc<AppState>>,
3667    axum::extract::Path(id): axum::extract::Path<String>,
3668) -> Result<Json<Value>, ApiError> {
3669    // freeze 404s an unknown session (probe); ocserve returned `[]`.
3670    let exists_id = id.clone();
3671    if !run_blocking(&st.db, move |db| {
3672        ocserve_store::session_exists(db, &exists_id)
3673    })
3674    .await
3675    .map_err(|e| ApiError {
3676        status: StatusCode::INTERNAL_SERVER_ERROR,
3677        name: "InternalError",
3678        message: format!("{e:#}"),
3679    })? {
3680        return Err(ApiError::not_found(format!("Session not found: {id}")));
3681    }
3682    run_blocking(&st.db, move |db| ocserve_store::load_todos(db, &id))
3683        .await
3684        .map(|v| Json(Value::Array(v)))
3685        .map_err(|e| ApiError {
3686            status: StatusCode::INTERNAL_SERVER_ERROR,
3687            name: "InternalError",
3688            message: format!("{e:#}"),
3689        })
3690}
3691
3692/// PATCH /session/{id} — oc-remote rename sends {title} only. Other
3693/// UpdatePayload fields (metadata/permission/time.archived) are not stored
3694/// by ocserve — accepted and ignored (documented divergence).
3695async fn patch_session(
3696    State(st): State<Arc<AppState>>,
3697    axum::extract::Path(id): axum::extract::Path<String>,
3698    Json(payload): Json<Value>,
3699) -> Result<Json<Value>, ApiError> {
3700    let exists_id = id.clone();
3701    if !run_blocking(&st.db, move |db| {
3702        ocserve_store::session_exists(db, &exists_id)
3703    })
3704    .await
3705    .map_err(|e| ApiError {
3706        status: StatusCode::INTERNAL_SERVER_ERROR,
3707        name: "InternalError",
3708        message: format!("{e:#}"),
3709    })? {
3710        return Err(ApiError::not_found(format!("Session not found: {id}")));
3711    }
3712    let mut dirty = false;
3713    if let Some(title) = payload.get("title").and_then(|t| t.as_str()) {
3714        ocserve_store::update_session_title(&st.writer, &id, title).map_err(|e| ApiError {
3715            status: StatusCode::INTERNAL_SERVER_ERROR,
3716            name: "InternalError",
3717            message: format!("{e:#}"),
3718        })?;
3719        dirty = true;
3720    }
3721    // K-MODEL-STATE: freeze Patch semantics for model/agent (oc-remote only
3722    // calls title; TUI/other clients patch model+agent). permission is
3723    // deliberately NOT patchable (no caller in evidence — ocserve's internal
3724    // key format differs from upstream's ruleset; divergence noted K row).
3725    if payload.get("model").is_some() {
3726        let raw = payload.get("model").cloned().unwrap_or(Value::Null);
3727        let model_json: Value = match raw {
3728            Value::Null => Value::Null, // clear → SQL NULL → resolve falls to default
3729            Value::Object(ref m) => {
3730                let ok = m
3731                    .get("id")
3732                    .and_then(|v| v.as_str())
3733                    .is_some_and(|v| !v.is_empty())
3734                    && m.get("providerID")
3735                        .and_then(|v| v.as_str())
3736                        .is_some_and(|v| !v.is_empty());
3737                if !ok {
3738                    return Err(ApiError {
3739                        status: StatusCode::BAD_REQUEST,
3740                        name: "BadRequest",
3741                        message: "model must be {id, providerID, variant?} or null".into(),
3742                    });
3743                }
3744                Value::String(
3745                    serde_json::to_string(&json!({
3746                        "id": m["id"],
3747                        "providerID": m["providerID"],
3748                        "variant": m.get("variant").cloned().unwrap_or(json!("default")),
3749                    }))
3750                    .map_err(|e| ApiError {
3751                        status: StatusCode::INTERNAL_SERVER_ERROR,
3752                        name: "InternalError",
3753                        message: format!("{e:#}"),
3754                    })?,
3755                )
3756            }
3757            _ => {
3758                return Err(ApiError {
3759                    status: StatusCode::BAD_REQUEST,
3760                    name: "BadRequest",
3761                    message: "model must be {id, providerID, variant?} or null".into(),
3762                });
3763            }
3764        };
3765        st.writer
3766            .write(vec![ocserve_store::WriteOp::Sql {
3767                sql: "UPDATE session SET model = ?2 WHERE id = ?1".into(),
3768                params: vec![id.clone().into(), model_json],
3769            }])
3770            .map_err(|e| ApiError {
3771                status: StatusCode::INTERNAL_SERVER_ERROR,
3772                name: "InternalError",
3773                message: format!("{e:#}"),
3774            })?;
3775        dirty = true;
3776    }
3777    if payload.get("agent").is_some() {
3778        let agent = match payload.get("agent") {
3779            Some(Value::Null) => String::new(),
3780            Some(Value::String(a)) if !a.is_empty() => a.clone(),
3781            _ => {
3782                return Err(ApiError {
3783                    status: StatusCode::BAD_REQUEST,
3784                    name: "BadRequest",
3785                    message: "agent must be a non-empty string or null".into(),
3786                });
3787            }
3788        };
3789        st.writer
3790            .write(vec![ocserve_store::WriteOp::Sql {
3791                sql: "UPDATE session SET agent = ?2 WHERE id = ?1".into(),
3792                params: vec![id.clone().into(), agent.into()],
3793            }])
3794            .map_err(|e| ApiError {
3795                status: StatusCode::INTERNAL_SERVER_ERROR,
3796                name: "InternalError",
3797                message: format!("{e:#}"),
3798            })?;
3799        dirty = true;
3800    }
3801    let info_id = id.clone();
3802    let info = run_blocking(&st.db, move |db| {
3803        ocserve_store::load_session_wire(db, &info_id)
3804    })
3805    .await
3806    .map_err(|e| ApiError {
3807        status: StatusCode::INTERNAL_SERVER_ERROR,
3808        name: "InternalError",
3809        message: format!("{e:#}"),
3810    })?
3811    .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))?;
3812    if dirty {
3813        // FULL session (already loaded above) so the event carries every
3814        // required key — a partial {id,title,model,agent} merges into client
3815        // stores and drops `time`/`version` (TUI/SDK store corruption).
3816        st.bus.publish(ocserve_core::event::frame(
3817            st.paths["directory"].as_str().unwrap_or("/"),
3818            "session.updated",
3819            json!({"sessionID": id, "info": info}),
3820        ));
3821    }
3822    Ok(Json(info))
3823}
3824
3825/// DELETE /session/{id} → true (v1 Schema.Boolean). Aborts any in-flight
3826/// async prompt first (it would write into a deleted session otherwise).
3827async fn delete_session_route(
3828    State(st): State<Arc<AppState>>,
3829    axum::extract::Path(id): axum::extract::Path<String>,
3830) -> Result<Json<Value>, ApiError> {
3831    if let Some((_, handle)) = st.prompt_tasks.lock().remove(&id) {
3832        handle.abort();
3833    }
3834    let found = ocserve_store::delete_session(&st.writer, &st.db, &id).map_err(|e| ApiError {
3835        status: StatusCode::INTERNAL_SERVER_ERROR,
3836        name: "InternalError",
3837        message: format!("{e:#}"),
3838    })?;
3839    if !found {
3840        return Err(ApiError::not_found(format!("Session not found: {id}")));
3841    }
3842    // RAM audit 2026-10-11: release per-session gate state (always-grants +
3843    // hydrated marker) — it otherwise accumulates for every session ever seen.
3844    st.gate.forget_session(&id);
3845    // P0e: magic-context consumes session.deleted (live publish — the
3846    // durable rows cascade away with the session).
3847    st.bus.publish(ocserve_core::event::frame(
3848        st.paths["directory"].as_str().unwrap_or("/"),
3849        "session.deleted",
3850        json!({"sessionID": id}),
3851    ));
3852    Ok(Json(json!(true)))
3853}
3854
3855/// POST /session/{id}/abort → true (idempotent: no active task = still true).
3856async fn post_abort(
3857    State(st): State<Arc<AppState>>,
3858    axum::extract::Path(id): axum::extract::Path<String>,
3859) -> Result<Json<Value>, ApiError> {
3860    if let Some((_, handle)) = st.prompt_tasks.lock().remove(&id) {
3861        handle.abort();
3862        tracing::info!("abort: cancelled prompt task for {id}");
3863    }
3864    Ok(Json(json!(true)))
3865}
3866
3867async fn delete_message_route(
3868    State(st): State<Arc<AppState>>,
3869    axum::extract::Path((sid, mid)): axum::extract::Path<(String, String)>,
3870) -> Result<Json<Value>, ApiError> {
3871    let found =
3872        ocserve_store::delete_message(&st.writer, &st.db, &sid, &mid).map_err(|e| ApiError {
3873            status: StatusCode::INTERNAL_SERVER_ERROR,
3874            name: "InternalError",
3875            message: format!("{e:#}"),
3876        })?;
3877    if !found {
3878        return Err(ApiError::not_found(format!("Message not found: {mid}")));
3879    }
3880    // P0e: magic-context consumes message.removed (live publish — durable
3881    // rows cascade away with the message).
3882    st.bus.publish(ocserve_core::event::frame(
3883        st.paths["directory"].as_str().unwrap_or("/"),
3884        "message.removed",
3885        json!({"sessionID": sid, "messageID": mid}),
3886    ));
3887    Ok(Json(json!(true)))
3888}
3889
3890async fn delete_part_route(
3891    State(st): State<Arc<AppState>>,
3892    axum::extract::Path((_sid, mid, pid)): axum::extract::Path<(String, String, String)>,
3893) -> Result<Json<Value>, ApiError> {
3894    ocserve_store::delete_part(&st.writer, &st.db, &mid, &pid)
3895        .map_err(|e| ApiError {
3896            status: StatusCode::INTERNAL_SERVER_ERROR,
3897            name: "InternalError",
3898            message: format!("{e:#}"),
3899        })?
3900        .then_some(Json(json!(true)))
3901        .ok_or_else(|| ApiError::not_found(format!("Part not found: {pid}")))
3902}
3903
3904/// PATCH .../part/{pid} — replace part JSON (native sessionID/messageID keys;
3905/// v1 verifies all three ids match the path — oc-remote pre-validates too).
3906async fn patch_part_route(
3907    State(st): State<Arc<AppState>>,
3908    axum::extract::Path((sid, mid, pid)): axum::extract::Path<(String, String, String)>,
3909    Json(part): Json<Value>,
3910) -> Result<Json<Value>, ApiError> {
3911    let body_sid = part.get("sessionID").and_then(|v| v.as_str()).unwrap_or("");
3912    let body_mid = part.get("messageID").and_then(|v| v.as_str()).unwrap_or("");
3913    let body_pid = part.get("id").and_then(|v| v.as_str()).unwrap_or("");
3914    if body_sid != sid || body_mid != mid || body_pid != pid {
3915        return Err(ApiError {
3916            status: StatusCode::BAD_REQUEST,
3917            name: "BadRequest",
3918            message: "part id/sessionID/messageID must match the path".into(),
3919        });
3920    }
3921    ocserve_store::update_part(&st.writer, &st.blobs, &sid, &mid, &pid, &part)
3922        .map_err(|e| ApiError {
3923            status: StatusCode::INTERNAL_SERVER_ERROR,
3924            name: "InternalError",
3925            message: format!("{e:#}"),
3926        })?
3927        .then_some(Json(part))
3928        .ok_or_else(|| ApiError::not_found(format!("Part not found: {pid}")))
3929}
3930
3931// ---- oc-remote contract family (Batch 2: file/find/question) ----
3932
3933fn scope_path(base: &std::path::Path, raw: &str) -> Result<std::path::PathBuf, ApiError> {
3934    let joined = if raw.is_empty() || raw == "." {
3935        base.to_path_buf()
3936    } else {
3937        let p = std::path::Path::new(raw);
3938        if p.is_absolute() {
3939            p.to_path_buf()
3940        } else {
3941            base.join(p)
3942        }
3943    };
3944    let canon_base = std::fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf());
3945    // Lexical containment FIRST: normalize `..` without touching the FS so an
3946    // escape is rejected deterministically even when the target is missing
3947    // (the previous code fell back to the raw `..`-laden path when canonicalize
3948    // failed, and a lexically-`starts_with` check on that path passed for a
3949    // traversal like `base/../../../etc`). Both the normalized join AND the
3950    // canonicalized real path must stay inside the base.
3951    let norm = lexically_normalize(&joined);
3952    if !norm.starts_with(&canon_base) && !norm.starts_with(base) {
3953        return Err(ApiError {
3954            status: StatusCode::BAD_REQUEST,
3955            name: "BadRequest",
3956            message: format!("path escapes the session directory: {raw}"),
3957        });
3958    }
3959    let canon = std::fs::canonicalize(&joined).unwrap_or(norm);
3960    if !canon.starts_with(&canon_base) {
3961        return Err(ApiError {
3962            status: StatusCode::BAD_REQUEST,
3963            name: "BadRequest",
3964            message: format!("path escapes the session directory: {raw}"),
3965        });
3966    }
3967    Ok(canon)
3968}
3969
3970/// Lexically resolve `.`/`..` (no filesystem access).
3971fn lexically_normalize(p: &std::path::Path) -> std::path::PathBuf {
3972    use std::path::Component;
3973    let mut out = std::path::PathBuf::new();
3974    for c in p.components() {
3975        match c {
3976            Component::ParentDir => {
3977                out.pop();
3978            }
3979            Component::CurDir => {}
3980            other => out.push(other.as_os_str()),
3981        }
3982    }
3983    out
3984}
3985
3986/// GET /file?path= — directory listing (FileNode list, oc-remote file browser).
3987async fn list_directory(
3988    State(st): State<Arc<AppState>>,
3989    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
3990) -> Result<Json<Value>, ApiError> {
3991    let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/"));
3992    let raw = q.get("path").map(String::as_str).unwrap_or(".");
3993    let dir = scope_path(base, raw)?;
3994    // M3 (same class as find_files): `read_dir` + per-entry `metadata()` is
3995    // blocking syscalls, inline on a tokio worker. A directory with thousands
3996    // of entries would park the worker for the whole loop.
3997    let dir_for_worker = dir.clone();
3998    let raw_owned = raw.to_string();
3999    let rd = tokio::task::spawn_blocking(move || std::fs::read_dir(&dir_for_worker))
4000        .await
4001        .map_err(|e| ApiError {
4002            status: StatusCode::INTERNAL_SERVER_ERROR,
4003            name: "InternalServerError",
4004            message: format!("list worker: {e}"),
4005        })?
4006        .map_err(|e| ApiError {
4007            status: StatusCode::BAD_REQUEST,
4008            name: "BadRequest",
4009            message: format!("list {}: {e}", dir.display()),
4010        })?;
4011    let mut nodes: Vec<Value> = Vec::new();
4012    for entry in rd.flatten() {
4013        let name = entry.file_name().to_string_lossy().to_string();
4014        let path = entry.path();
4015        let meta = entry.metadata().ok();
4016        let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
4017        let rel = if raw_owned.is_empty() || raw_owned == "." {
4018            name.clone()
4019        } else {
4020            format!("{}/{}", raw_owned.trim_end_matches('/'), name)
4021        };
4022        let ignored =
4023            name.starts_with('.') || name == "node_modules" || name == "target" || name == ".git";
4024        // Freeze FileNode = {name,path,absolute,type,ignored} with
4025        // `additionalProperties:false` (spec probed 2026-10-09). ocserve
4026        // previously added `size`/`modified`, which freeze never emits and
4027        // no client can depend on — the spec-validation oracle (P4) caught
4028        // it. Emit exactly the freeze keys.
4029        nodes.push(json!({
4030            "name": name,
4031            "path": rel,
4032            "type": if is_dir { "directory" } else { "file" },
4033            "absolute": path.to_string_lossy(),
4034            "ignored": ignored,
4035        }));
4036    }
4037    // directories first, then name (browser expectations), bounded
4038    nodes.sort_by(|a, b| {
4039        let da = a["type"] == "directory";
4040        let db = b["type"] == "directory";
4041        db.cmp(&da)
4042            .then_with(|| a["name"].as_str().cmp(&b["name"].as_str()))
4043    });
4044    nodes.truncate(5000);
4045    Ok(Json(Value::Array(nodes)))
4046}
4047
4048/// GET /file/content?path= — {type: text|binary, content, encoding}.
4049async fn read_file_content(
4050    State(st): State<Arc<AppState>>,
4051    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
4052) -> Result<Json<Value>, HttpError> {
4053    let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/"));
4054    let raw = q
4055        .get("path")
4056        .map(String::as_str)
4057        .ok_or_else(|| HttpError::Query {
4058            message: "Missing key\n  at [\"path\"]".into(),
4059        })?;
4060    let path = scope_path(base, raw).map_err(HttpError::Api)?;
4061    // freeze returns an empty text body for a missing file (probed), not an
4062    // error — the web UI probes optional paths.
4063    // Perf dive 2026-10-11: the read ran INLINE on a tokio worker (up to 1 MB
4064    // blocking fs + copy — the M3 class). Move it to the blocking pool.
4065    let read_path = path.clone();
4066    let bytes = match tokio::task::spawn_blocking(move || std::fs::read(&read_path)).await {
4067        Ok(Ok(b)) => b,
4068        _ => {
4069            return Ok(Json(json!({"type": "text", "content": ""})));
4070        }
4071    };
4072    // bound: MEMORY §6 parse cap — truncate at 1MB, then floor to a UTF-8 char
4073    // boundary so a multibyte char straddling the cutoff does not make a text
4074    // file mislabel as "binary".
4075    let truncated = bytes.len() > 1024 * 1024;
4076    let bytes = if truncated {
4077        let mut cut = 1024 * 1024;
4078        while cut > 0 && (bytes[cut] & 0xC0) == 0x80 {
4079            cut -= 1;
4080        }
4081        &bytes[..cut]
4082    } else {
4083        &bytes[..]
4084    };
4085    let (kind, content) = match std::str::from_utf8(bytes) {
4086        Ok(text) => ("text", text.to_string()),
4087        Err(_) => ("binary", String::from_utf8_lossy(bytes).into_owned()),
4088    };
4089    Ok(Json(json!({
4090        "type": kind,
4091        "content": content,
4092        "encoding": "utf-8",
4093    })))
4094}
4095
4096/// GET /find/file?query=&type=&limit= — file-name/path search → List<String>
4097/// (bounded walk; case-insensitive substring, glob when the query has *).
4098async fn find_files(
4099    State(st): State<Arc<AppState>>,
4100    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
4101) -> Result<Json<Value>, HttpError> {
4102    let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/")).to_path_buf();
4103    // `query` is REQUIRED (freeze Query decode: missing → 400, kind Query);
4104    // an empty value is accepted (→ no matches).
4105    let query = q
4106        .get("query")
4107        .ok_or_else(|| HttpError::Query {
4108            message: "Missing key\n  at [\"query\"]".into(),
4109        })?
4110        .to_lowercase();
4111    if query.is_empty() {
4112        return Ok(Json(json!([])));
4113    }
4114    let want_type = q.get("type").map(String::as_str).unwrap_or("").to_string();
4115    // freeze default limit = 10 (ripgrep find limit, file.ts:47)
4116    let limit: usize = q
4117        .get("limit")
4118        .and_then(|l| l.parse().ok())
4119        .unwrap_or(10)
4120        .clamp(1, 200);
4121    let glob_mode = query.contains('*') || query.contains('?');
4122    // M3: the walk is a blocking filesystem scan (bounded at 20,000 dirs, but
4123    // a big worktree still means thousands of `read_dir` syscalls per
4124    // request). It ran INLINE on a tokio worker — the same convoy class as the
4125    // pre-F1 SQLite work, which is exactly what `run_blocking` exists for.
4126    // The load harness never caught this because its LIST_PATH is an empty
4127    // /tmp dir; on a real worktree it would park a worker.
4128    let out = tokio::task::spawn_blocking(move || {
4129        walk_files(&base, &query, &want_type, limit, glob_mode)
4130    })
4131    .await
4132    .map_err(|e| {
4133        HttpError::Api(ApiError {
4134            status: StatusCode::INTERNAL_SERVER_ERROR,
4135            name: "InternalServerError",
4136            message: format!("find_files worker: {e}"),
4137        })
4138    })?
4139    .map_err(|e| {
4140        HttpError::Api(ApiError {
4141            status: StatusCode::BAD_REQUEST,
4142            name: "BadRequest",
4143            message: format!("find_files: {e:#}"),
4144        })
4145    })?;
4146    Ok(Json(Value::Array(
4147        out.into_iter().map(Value::String).collect(),
4148    )))
4149}
4150
4151/// Thread the last `walk_files` executed on (M3 test seam).
4152///
4153/// Global, not `thread_local!`: the walk runs on a *blocking-pool* thread, so
4154/// a thread-local record would be written on one thread and read on another —
4155/// the first version of this seam was invisible for exactly that reason (caught
4156/// by the test asserting on it).
4157static WALK_THREAD: std::sync::Mutex<Option<std::thread::ThreadId>> = std::sync::Mutex::new(None);
4158
4159/// Blocking body of `GET /find/file` — a bounded, single-threaded directory
4160/// walk. Existing bounds kept exactly (20,000 dirs visited, stop once the
4161/// result set reaches 4×limit); only the *thread* it runs on changed.
4162pub(crate) fn walk_files(
4163    base: &std::path::Path,
4164    query: &str,
4165    want_type: &str,
4166    limit: usize,
4167    glob_mode: bool,
4168) -> anyhow::Result<Vec<String>> {
4169    // M3 test seam: which thread executed the walk. Async tokio workers and
4170    // the blocking pool are different threads, so comparing this with the
4171    // thread a request body runs on proves the walk was moved OFF the worker
4172    // without depending on wall-clock timing (a small tree walks in
4173    // microseconds — the first version of this test used a ticker and passed
4174    // even with the walk inline, i.e. it was not discriminating).
4175    if let Ok(mut g) = WALK_THREAD.lock() {
4176        *g = Some(std::thread::current().id());
4177    }
4178    let mut out: Vec<String> = Vec::new();
4179    let mut stack = vec![(base.to_path_buf(), String::new())];
4180    let mut visited = 0usize;
4181    while let Some((dir, rel)) = stack.pop() {
4182        visited += 1;
4183        if visited > 20_000 || out.len() >= limit * 4 {
4184            break; // bounded (AGENTS §2.3)
4185        }
4186        let Ok(rd) = std::fs::read_dir(&dir) else {
4187            continue;
4188        };
4189        for entry in rd.flatten() {
4190            let name = entry.file_name().to_string_lossy().to_string();
4191            if name == ".git" || name == "node_modules" {
4192                continue;
4193            }
4194            let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
4195            let child_rel = if rel.is_empty() {
4196                name.clone()
4197            } else {
4198                format!("{rel}/{name}")
4199            };
4200            let matched = if glob_mode {
4201                ocserve_tools::wildcard_match(&name, query)
4202            } else {
4203                child_rel.to_lowercase().contains(query)
4204            };
4205            if matched
4206                && (want_type.is_empty()
4207                    || (want_type == "dir" && is_dir)
4208                    || (want_type == "file" && !is_dir))
4209            {
4210                out.push(child_rel.clone());
4211            }
4212            if is_dir {
4213                stack.push((entry.path(), child_rel));
4214            }
4215        }
4216    }
4217    // prefix matches first, then lexicographic; capped
4218    out.sort_by(|a, b| {
4219        let pa = a.to_lowercase().starts_with(query);
4220        let pb = b.to_lowercase().starts_with(query);
4221        pb.cmp(&pa).then_with(|| a.cmp(b))
4222    });
4223    out.truncate(limit);
4224    Ok(out)
4225}
4226
4227/// GET /find?pattern= — text search → SearchMatch list. Single `grep -rnE`
4228/// spawn (bounded output, 5s); absoluteOffset is not derivable from grep
4229/// output and this route has no callers in oc-remote — recorded as 0
4230/// (documented, not silently invented).
4231async fn find_text(
4232    State(st): State<Arc<AppState>>,
4233    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
4234) -> Result<Json<Value>, HttpError> {
4235    // `pattern` is REQUIRED (freeze Query decode: missing → 400, kind Query);
4236    // empty value accepted (→ no matches).
4237    let pattern = q.get("pattern").ok_or_else(|| HttpError::Query {
4238        message: "Missing key\n  at [\"pattern\"]".into(),
4239    })?;
4240    if pattern.is_empty() {
4241        return Ok(Json(json!([])));
4242    }
4243    let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/")).to_path_buf();
4244    let pattern = pattern.to_string();
4245    // Freeze uses ripgrep (gitignore-aware). ocserve shells to grep, so we must
4246    // approximate the ignore behaviour: skip VCS/build/vendor dirs. Without
4247    // this, `/find` scanned `target/` (gigabytes) and hung the request — a real
4248    // bug (freeze returns matches instantly).
4249    const EXCLUDES: &[&str] = &[
4250        ".git",
4251        "node_modules",
4252        "target",
4253        ".venv",
4254        "venv",
4255        "__pycache__",
4256        "dist",
4257        "build",
4258        ".terraform",
4259        ".terragrunt-cache",
4260    ];
4261    let base2 = base.clone();
4262    // Two bounded grep passes (both on the blocking pool):
4263    //   A: line-level → path:line:lineByte:content  (path.text/lines.text/line_number/absolute_offset)
4264    //   B: match-level → path:line:matchByte:matchText  (submatches[{match.text,start,end}])
4265    // freeze's SearchMatch is {path:{text}, lines:{text}, line_number,
4266    // absolute_offset, submatches:[{match:{text}, start, end}]} with start/end
4267    // as byte offsets WITHIN the line (ripgrep.ts:56-72).
4268    let out = tokio::task::spawn_blocking(move || {
4269        let run = |extra: &[&str]| -> std::io::Result<std::process::Output> {
4270            let mut c = std::process::Command::new("grep");
4271            c.args(["-rnE", "--binary-files=without-match", "-m", "50"]);
4272            c.args(extra);
4273            for d in EXCLUDES {
4274                c.arg(format!("--exclude-dir={d}"));
4275            }
4276            c.arg("--").arg(&pattern).arg(&base2);
4277            c.env("LC_ALL", "C").output()
4278        };
4279        // A first (line-level, -n) then B (-o -b for each match offset)
4280        let a = run(&["-n"])?;
4281        let b = run(&["-on", "-b"])?;
4282        Ok::<_, std::io::Error>((a, b))
4283    })
4284    .await
4285    .map_err(|e| {
4286        HttpError::Api(ApiError {
4287            status: StatusCode::INTERNAL_SERVER_ERROR,
4288            name: "InternalError",
4289            message: format!("find worker: {e}"),
4290        })
4291    })?;
4292    let (a_out, b_out) = match out {
4293        Ok(o) => o,
4294        Err(e) => {
4295            return Err(HttpError::Api(ApiError {
4296                status: StatusCode::INTERNAL_SERVER_ERROR,
4297                name: "InternalError",
4298                message: format!("grep spawn: {e}"),
4299            }));
4300        }
4301    };
4302    if a_out.status.code().unwrap_or(0) > 1 {
4303        return Err(HttpError::Api(ApiError {
4304            status: StatusCode::BAD_REQUEST,
4305            name: "BadRequest",
4306            message: "invalid pattern".into(),
4307        }));
4308    }
4309    // match-level: (path, line) -> [(byteoffset, text)]
4310    let mut subs: std::collections::HashMap<(String, i64), Vec<(usize, String)>> =
4311        std::collections::HashMap::new();
4312    for line in String::from_utf8_lossy(&b_out.stdout).lines() {
4313        // path:line:byteoffset:text  (grep -o prints one match per output line)
4314        if let Some((head, text)) = split3(line) {
4315            let (path, ln, byte) = head;
4316            subs.entry((path, ln)).or_default().push((byte, text));
4317        }
4318    }
4319    let mut matches: Vec<Value> = Vec::new();
4320    for line in String::from_utf8_lossy(&a_out.stdout).lines().take(200) {
4321        // path:line:content (grep -n, no -b → third field is the content)
4322        let (p1, rest) = match line.split_once(':') {
4323            Some(x) => x,
4324            None => continue,
4325        };
4326        let (ln, text) = match rest.split_once(':') {
4327            Some(x) => x,
4328            None => continue,
4329        };
4330        let ln: i64 = ln.parse().unwrap_or(0);
4331        let line_start = text.as_ptr() as usize; // unused; offsets come from pass B
4332        let _ = line_start;
4333        let rel = std::path::Path::new(p1)
4334            .strip_prefix(&base)
4335            .map(|r| r.to_string_lossy().to_string())
4336            .unwrap_or_else(|_| p1.to_string());
4337        // absolute_offset = byte offset of the line's start within the file
4338        // (freeze = ripgrep line start). Derive it from the first match's byte
4339        // offset minus its in-line start; grep -m/-n gives us line text only, so
4340        // compute the in-line start by locating the match text in the line.
4341        let line_subs = subs.get(&(p1.to_string(), ln)).cloned().unwrap_or_default();
4342        let mut submatch_json = Vec::new();
4343        let mut absolute_offset: i64 = 0;
4344        for (i, (byteoff, mtext)) in line_subs.iter().enumerate() {
4345            // in-line byte start = find mtext in the line (first occurrence for
4346            // the first sub; subsequent matches advance past prior ones)
4347            let from = submatch_json
4348                .last()
4349                .and_then(|v: &Value| v["end"].as_u64())
4350                .map(|e| e as usize + 1)
4351                .unwrap_or(0);
4352            let start = text[from.min(text.len())..]
4353                .find(mtext.as_str())
4354                .map(|p| p + from.min(text.len()))
4355                .unwrap_or(0);
4356            let end = start + mtext.len();
4357            if i == 0 {
4358                absolute_offset = (*byteoff as i64) - (start as i64);
4359            }
4360            submatch_json.push(json!({
4361                "match": {"text": mtext},
4362                "start": start,
4363                "end": end,
4364            }));
4365        }
4366        matches.push(json!({
4367            "path": {"text": rel},
4368            "lines": {"text": format!("{text}\n")},
4369            "line_number": ln,
4370            "absolute_offset": absolute_offset,
4371            "submatches": submatch_json,
4372        }));
4373    }
4374    Ok(Json(Value::Array(matches)))
4375}
4376
4377/// Parse `path:line:byteoffset:text` (grep -o -b output; text may contain ':').
4378fn split3(line: &str) -> Option<((String, i64, usize), String)> {
4379    let (path, rest) = line.split_once(':')?;
4380    let (ln, rest) = rest.split_once(':')?;
4381    let (byte, text) = rest.split_once(':')?;
4382    Some((
4383        (path.to_string(), ln.parse().ok()?, byte.parse().ok()?),
4384        text.to_string(),
4385    ))
4386}
4387
4388/// GET /question — all pending question requests (v1 Question.Request list;
4389/// oc-remote ConnectionService polls this while a dialog may be open).
4390async fn get_questions(State(st): State<Arc<AppState>>) -> Json<Value> {
4391    Json(Value::Array(st.question_gate.list()))
4392}
4393
4394/// Validate a `que_`/`per_`-prefixed request id (freeze decode precedes the
4395/// 404/200: bad prefix → 400 Params).
4396pub(crate) fn validate_request_prefix(id: &str, prefix: &str) -> Result<(), HttpError> {
4397    if id.starts_with(prefix) {
4398        Ok(())
4399    } else {
4400        Err(HttpError::Params {
4401            message: format!(
4402                "Expected a string starting with \"{prefix}\", got \"{id}\"\n  at [\"requestID\"]"
4403            ),
4404        })
4405    }
4406}
4407
4408/// Tagged QuestionNotFoundError (freeze v2 + v1 question routes).
4409pub(crate) fn question_not_found(id: &str) -> HttpError {
4410    HttpError::TaggedData {
4411        status: StatusCode::NOT_FOUND,
4412        tag: "QuestionNotFoundError",
4413        fields: json!({"requestID": id, "message": format!("Question request not found: {id}")}),
4414    }
4415}
4416
4417/// POST /question/{id}/reply — {answers: [[labels]]} (v1 Reply payload) → true.
4418async fn post_question_reply(
4419    State(st): State<Arc<AppState>>,
4420    axum::extract::Path(id): axum::extract::Path<String>,
4421    Json(body): Json<Value>,
4422) -> Result<Json<Value>, HttpError> {
4423    validate_request_prefix(&id, "que")?;
4424    let raw = body
4425        .get("answers")
4426        .and_then(|a| a.as_array())
4427        .ok_or_else(|| {
4428            HttpError::Api(ApiError {
4429                status: StatusCode::BAD_REQUEST,
4430                name: "BadRequest",
4431                message: "body must be {answers: [[String]]}".into(),
4432            })
4433        })?;
4434    let mut answers: Vec<Vec<String>> = Vec::with_capacity(raw.len());
4435    for a in raw {
4436        let row = a.as_array().ok_or_else(|| {
4437            HttpError::Api(ApiError {
4438                status: StatusCode::BAD_REQUEST,
4439                name: "BadRequest",
4440                message: "each answer must be an array of labels".into(),
4441            })
4442        })?;
4443        answers.push(
4444            row.iter()
4445                .map(|l| l.as_str().unwrap_or_default().to_string())
4446                .collect(),
4447        );
4448    }
4449    if st.question_gate.reply(&id, answers) {
4450        Ok(Json(json!(true)))
4451    } else {
4452        Err(question_not_found(&id))
4453    }
4454}
4455
4456/// POST /question/{id}/reject — no body (oc-remote posts empty) → true.
4457async fn post_question_reject(
4458    State(st): State<Arc<AppState>>,
4459    axum::extract::Path(id): axum::extract::Path<String>,
4460) -> Result<Json<Value>, HttpError> {
4461    validate_request_prefix(&id, "que")?;
4462    if st.question_gate.reject(&id) {
4463        Ok(Json(json!(true)))
4464    } else {
4465        Err(question_not_found(&id))
4466    }
4467}
4468
4469// ---- oc-remote contract family (Batch 4: command/shell) ----
4470
4471/// v1 argsRegex tokenizer: quoted strings or non-space runs, then
4472/// quoteTrimRegex strips one leading/trailing quote per token.
4473pub fn split_command_args(arguments: &str) -> Vec<String> {
4474    let bytes = arguments.as_bytes();
4475    let mut out = Vec::new();
4476    let mut i = 0;
4477    while i < bytes.len() {
4478        let c = bytes[i];
4479        if c.is_ascii_whitespace() {
4480            i += 1;
4481            continue;
4482        }
4483        let (tok, next) = if c == b'"' || c == b'\'' {
4484            let quote = c;
4485            let mut j = i + 1;
4486            while j < bytes.len() && bytes[j] != quote {
4487                j += 1;
4488            }
4489            let end = (j + 1).min(bytes.len());
4490            (
4491                std::str::from_utf8(&bytes[i..end])
4492                    .unwrap_or("")
4493                    .to_string(),
4494                end,
4495            )
4496        } else {
4497            let mut j = i;
4498            while j < bytes.len() && !bytes[j].is_ascii_whitespace() {
4499                j += 1;
4500            }
4501            (
4502                std::str::from_utf8(&bytes[i..j]).unwrap_or("").to_string(),
4503                j,
4504            )
4505        };
4506        // quoteTrim: strip one leading then one trailing quote char
4507        let mut t = tok.as_str();
4508        if t.starts_with(['"', '\'']) {
4509            t = &t[1..];
4510        }
4511        if t.ends_with(['"', '\'']) && !t.is_empty() {
4512            t = &t[..t.len() - 1];
4513        }
4514        out.push(t.to_string());
4515        i = next;
4516    }
4517    out
4518}
4519
4520/// v1 command template expansion (prompt.ts command()): $N placeholders (last
4521/// position swallows the remaining args), then $ARGUMENTS, then append rule.
4522/// `!`…`` shell snippets (ConfigMarkdown.shell) are NOT evaluated — neither
4523/// built-in template uses them (documented gap for exotic config commands).
4524pub fn expand_command(template: &str, arguments: &str) -> String {
4525    let args = split_command_args(arguments);
4526    // max placeholder position
4527    let mut last = 0usize;
4528    let b = template.as_bytes();
4529    let mut i = 0;
4530    while i + 1 < b.len() {
4531        if b[i] == b'$' && b[i + 1].is_ascii_digit() {
4532            let mut j = i + 1;
4533            while j < b.len() && b[j].is_ascii_digit() {
4534                j += 1;
4535            }
4536            if let Some(n) = std::str::from_utf8(&b[i + 1..j])
4537                .ok()
4538                .and_then(|d| d.parse::<usize>().ok())
4539            {
4540                last = last.max(n);
4541            }
4542            i = j;
4543        } else {
4544            i += 1;
4545        }
4546    }
4547    // replace $N
4548    let mut with_args = String::new();
4549    let mut i = 0;
4550    let mut had_placeholder = false;
4551    while i < b.len() {
4552        if b[i] == b'$' && i + 1 < b.len() && b[i + 1].is_ascii_digit() {
4553            let mut j = i + 1;
4554            while j < b.len() && b[j].is_ascii_digit() {
4555                j += 1;
4556            }
4557            let n: usize = std::str::from_utf8(&b[i + 1..j])
4558                .unwrap_or("0")
4559                .parse()
4560                .unwrap_or(0);
4561            had_placeholder = true;
4562            let arg_index = n.saturating_sub(1);
4563            let value = if arg_index >= args.len() {
4564                String::new()
4565            } else if n == last {
4566                args[arg_index..].join(" ")
4567            } else {
4568                args[arg_index].clone()
4569            };
4570            with_args.push_str(&value);
4571            i = j;
4572        } else {
4573            // copy one utf-8 char
4574            let ch_len = {
4575                let ch = template[i..].chars().next().unwrap_or(' ');
4576                ch.len_utf8()
4577            };
4578            with_args.push_str(&template[i..i + ch_len]);
4579            i += ch_len;
4580        }
4581    }
4582    let uses_arguments = template.contains("$ARGUMENTS");
4583    let mut expanded = with_args.replace("$ARGUMENTS", arguments);
4584    if !had_placeholder && !uses_arguments && !arguments.trim().is_empty() {
4585        expanded = format!("{expanded}\n\n{arguments}");
4586    }
4587    expanded.trim().to_string()
4588}
4589
4590/// v1 Session.Event.Error on unknown commands (oc-remote toast via
4591/// session.error SSE) + durable+s twin.
4592fn emit_session_error_event(st: &Arc<AppState>, sid: &str, message: &str) {
4593    let props = json!({
4594        "sessionID": sid,
4595        "error": {"name": "UnknownError", "data": {"message": message}},
4596    });
4597    if let Err(e) = ocserve_store::append_event(&st.writer, Some(sid), "session.error", &props) {
4598        tracing::warn!("session.error persist failed: {e:#}");
4599    }
4600    let seq = ocserve_store::next_event_seq(&st.db, sid).unwrap_or(0);
4601    let dir = st.paths["directory"].as_str().unwrap_or("/");
4602    st.bus.publish(ocserve_core::event::frame(
4603        dir,
4604        "session.error",
4605        props.clone(),
4606    ));
4607    st.bus.publish(ocserve_core::event::sync_frame(
4608        dir,
4609        "session.error",
4610        props,
4611        seq as u64,
4612        sid,
4613    ));
4614}
4615
4616/// Busy rejection (v1 mapBusy → SessionBusyError): command/shell never queue
4617/// — they fail fast when the session is mid-prompt (upstream semantics).
4618pub(crate) fn session_busy(id: &str) -> ApiError {
4619    ApiError {
4620        status: StatusCode::CONFLICT,
4621        name: "SessionBusyError",
4622        message: format!("Session is busy: {id}"),
4623    }
4624}
4625
4626/// POST /session/{id}/command — v1 promptSvc.command: look up the command
4627/// (built-ins init/review + config), expand the template, run the prompt
4628/// synchronously, return WithParts. Unknown → session.error SSE + 400.
4629async fn post_command(
4630    State(st): State<Arc<AppState>>,
4631    axum::extract::Path(id): axum::extract::Path<String>,
4632    Json(payload): Json<Value>,
4633) -> Result<Json<Value>, HttpError> {
4634    // decode FIRST (HttpApi payload decode precedes handler — property order
4635    // messageID → agent → model → arguments → command, probe-pinned).
4636    let cmd = decode_v1(ocserve_core::wire::CommandRequest::decode(&payload))?;
4637    let exists_id = id.clone();
4638    if !run_blocking(&st.db, move |db| {
4639        ocserve_store::session_exists(db, &exists_id)
4640    })
4641    .await
4642    .map_err(|e| ApiError {
4643        status: StatusCode::INTERNAL_SERVER_ERROR,
4644        name: "InternalError",
4645        message: format!("{e:#}"),
4646    })? {
4647        return Err(ApiError::not_found(format!("Session not found: {id}")).into());
4648    }
4649    let name = cmd.command.as_str();
4650    let arguments = cmd.arguments.as_str();
4651    // scoped: parking_lot guards are !Send — never held across run_prompt's
4652    // .await (Handler would stop accepting the future)
4653    let (template, available_owned): (Option<String>, Vec<String>) = {
4654        let payloads = st.payloads.read();
4655        let t = payloads
4656            .command
4657            .iter()
4658            .find(|c| c["name"].as_str() == Some(name))
4659            .and_then(|c| c["template"].as_str())
4660            .map(str::to_string);
4661        let names = payloads
4662            .command
4663            .iter()
4664            .filter_map(|c| c["name"].as_str().map(str::to_string))
4665            .collect();
4666        (t, names)
4667    };
4668    let available: Vec<&str> = available_owned.iter().map(String::as_str).collect();
4669    let Some(template) = template.as_deref() else {
4670        let msg = format!(
4671            "Command not found: \"{name}\".{}",
4672            if available.is_empty() {
4673                String::new()
4674            } else {
4675                format!(" Available commands: {}", available.join(", "))
4676            }
4677        );
4678        emit_session_error_event(&st, &id, &msg);
4679        return Err(ApiError {
4680            status: StatusCode::BAD_REQUEST,
4681            name: "BadRequest",
4682            message: msg,
4683        }
4684        .into());
4685    };
4686    let expanded = expand_command(template, arguments);
4687    let mut cmd_parts = json!([{ "type": "text", "text": expanded }]);
4688    // v1 parity: command.execute.before (prompt.ts:1461) — input
4689    // {command, sessionID, arguments}, output {parts} mutated before the
4690    // prompt runs. Fail-open (no sidecar/hook error → parts unchanged).
4691    let parts_out = ocserve_core::prompt::hook_mutate(
4692        st.plugins.get(),
4693        "command.execute.before",
4694        json!({
4695            "command": name,
4696            "sessionID": id,
4697            "arguments": arguments,
4698        }),
4699        json!({"parts": cmd_parts.clone()}),
4700    )
4701    .await;
4702    if let Some(p) = parts_out.get("parts").and_then(|p| p.as_array()) {
4703        cmd_parts = Value::Array(p.clone());
4704    }
4705
4706    // Command wire `model` is a STRING (`providerID/modelID`) — parsed with
4707    // upstream `Provider.parseModel` semantics. The previous Value-path read
4708    // `/model/providerID` off the string and silently fell back to the
4709    // session/default model.
4710    let req = ocserve_core::wire::PromptRequest {
4711        message_id: cmd.message_id.clone(),
4712        model: cmd
4713            .model
4714            .as_deref()
4715            .map(ocserve_core::wire::parse_model_str),
4716        agent: cmd.agent.clone(),
4717        no_reply: false,
4718        tools: None,
4719        format: None,
4720        system: None,
4721        variant: cmd.variant.clone(),
4722        parts: cmd_parts.as_array().cloned().unwrap_or_default(),
4723    };
4724    let ctx = build_prompt_context(&st, &req, &id)?;
4725    let _release = lock_session(&st, &id).await?;
4726    let Ok(_guard) = _release.arc().try_lock() else {
4727        return Err(session_busy(&id).into());
4728    };
4729    let writer = st.writer.clone();
4730    let (info, parts) = ocserve_core::prompt::run_prompt_with(
4731        &ctx,
4732        &writer,
4733        &id,
4734        &req,
4735        ocserve_core::prompt::RunOpts {
4736            // K-TITLE: a command must never name the session after itself
4737            auto_title: false,
4738            ..Default::default()
4739        },
4740    )
4741    .await
4742    .map_err(prompt_err)?;
4743    drop(_guard);
4744    Ok(Json(json!({"info": info, "parts": parts})))
4745}
4746
4747/// POST /session/{id}/shell — v1 promptSvc.shell: NO model. Runs the command
4748/// directly (bash), records synthetic-user + assistant/bash tool messages,
4749/// returns WithParts. Port note: shell.env plugin hook skipped (no configured
4750/// plugin subscribes); abort-of-running-shell not wired (documented).
4751async fn post_shell(
4752    State(st): State<Arc<AppState>>,
4753    axum::extract::Path(id): axum::extract::Path<String>,
4754    Json(payload): Json<Value>,
4755) -> Result<Json<Value>, HttpError> {
4756    // decode FIRST: probe `{}` → `Missing key at ["agent"]` (freeze's
4757    // custom "command required" messages never existed upstream).
4758    let sh = decode_v1(ocserve_core::wire::ShellRequest::decode(&payload))?;
4759    let exists_id = id.clone();
4760    if !run_blocking(&st.db, move |db| {
4761        ocserve_store::session_exists(db, &exists_id)
4762    })
4763    .await
4764    .map_err(|e| ApiError {
4765        status: StatusCode::INTERNAL_SERVER_ERROR,
4766        name: "InternalError",
4767        message: format!("{e:#}"),
4768    })? {
4769        return Err(ApiError::not_found(format!("Session not found: {id}")).into());
4770    }
4771    let command = sh.command.as_str();
4772    let agent = sh.agent.as_str();
4773    let (pid, mid) = resolve_model(&st, sh.model.as_ref(), &id);
4774
4775    let _release = lock_session(&st, &id).await?;
4776    let Ok(_guard) = _release.arc().try_lock() else {
4777        return Err(session_busy(&id).into());
4778    };
4779    let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
4780
4781    // busy → run → idle (client spinner parity)
4782    let status_frame = |kind: &str| {
4783        ocserve_core::event::frame(
4784            &dir,
4785            "session.status",
4786            json!({"sessionID": id, "status": {"type": kind}}),
4787        )
4788    };
4789    st.bus.publish(status_frame("busy"));
4790
4791    let started = now_ms_local();
4792    // client-supplied id honored like every other prompt-family route
4793    // (probe-unverified for shell specifically — field-probes.md).
4794    let user_msg_id = sh
4795        .message_id
4796        .clone()
4797        .unwrap_or_else(ocserve_core::ids::msg_id);
4798    let user_part = json!({
4799        "type": "text", "id": ocserve_core::ids::prt_id(),
4800        "sessionID": id, "messageID": user_msg_id,
4801        "text": "The following tool was executed by the user",
4802        "synthetic": true,
4803    });
4804    let user_info = json!({
4805        "id": user_msg_id,
4806        "sessionID": id,
4807        "role": "user",
4808        "agent": agent,
4809        "model": {"providerID": pid, "modelID": mid},
4810        "time": {"created": started},
4811        "summary": {"diffs": []},
4812    });
4813    let assistant_id = ocserve_core::ids::msg_id();
4814    let call_id = ocserve_core::ids::evt_id();
4815    let mut tool_part = json!({
4816        "type": "tool", "id": ocserve_core::ids::prt_id(),
4817        "sessionID": id, "messageID": assistant_id,
4818        "callID": call_id, "tool": "bash",
4819        "state": {
4820            "status": "running",
4821            "input": {"command": command},
4822            "time": {"start": started},
4823        },
4824    });
4825
4826    // persist user msg + assistant msg, emit running state (upstream order)
4827    let writer = st.writer.clone();
4828    insert_message_http(
4829        &writer,
4830        &st,
4831        &id,
4832        &user_info,
4833        std::slice::from_ref(&user_part),
4834    )?;
4835    let assistant_info = json!({
4836        "id": assistant_id,
4837        "parentID": user_msg_id,
4838        "role": "assistant",
4839        "mode": agent,
4840        "agent": agent,
4841        "path": {"cwd": dir, "root": "/"},
4842        "cost": 0.0,
4843        "tokens": {"total": 0, "input": 0, "output": 0, "reasoning": 0,
4844                   "cache": {"write": 0, "read": 0}},
4845        "modelID": mid,
4846        "providerID": pid,
4847        "time": {"created": started, "completed": started},
4848        "finish": "stop",
4849        "id": assistant_id,
4850        "sessionID": id,
4851    });
4852    insert_message_http(&writer, &st, &id, &assistant_info, &[])?;
4853    emit_durable_http(
4854        &st,
4855        &id,
4856        "message.updated",
4857        json!({"sessionID": id, "info": user_info.clone()}),
4858    )?;
4859    emit_durable_http(
4860        &st,
4861        &id,
4862        "message.updated",
4863        json!({"sessionID": id, "info": assistant_info.clone()}),
4864    )?;
4865    emit_durable_http(
4866        &st,
4867        &id,
4868        "message.part.updated",
4869        json!({"sessionID": id, "part": user_part}),
4870    )?;
4871    emit_durable_http(
4872        &st,
4873        &id,
4874        "message.part.updated",
4875        json!({"sessionID": id, "part": tool_part.clone()}),
4876    )?;
4877
4878    // execute directly (bash executor: stderr merged, output bounded,
4879    // default 120s cap — ShellInput carries no timeout). Off-worker: a long
4880    // shell command must not pin a tokio worker (K-EFFICIENCY).
4881    let exec = {
4882        let cmd = json!({"command": command});
4883        let d = dir.clone();
4884        tokio::task::spawn_blocking(move || {
4885            ocserve_tools::execute("bash", &cmd, std::path::Path::new(&d))
4886        })
4887        .await
4888        .map_err(|e| ApiError {
4889            status: StatusCode::INTERNAL_SERVER_ERROR,
4890            name: "InternalError",
4891            message: format!("tool join: {e}"),
4892        })?
4893    };
4894    let (status, output) = match exec {
4895        Ok(r) => ("completed", r.output),
4896        Err(e) => ("error", format!("Error: {e:#}")),
4897    };
4898    let completed = now_ms_local();
4899    // v1 finish() shape: completed always carries {title:"", output,
4900    // metadata:{output}} — no exit field (capture-verified)
4901    tool_part["state"] = json!({
4902        "status": status,
4903        "input": {"command": command},
4904        "output": output,
4905        "title": "",
4906        "metadata": {"output": ""},
4907        "time": {"start": started, "end": completed},
4908    });
4909    tool_part["state"]["metadata"]["output"] = tool_part["state"]["output"].clone();
4910    insert_message_http(
4911        &writer,
4912        &st,
4913        &id,
4914        &assistant_info,
4915        std::slice::from_ref(&tool_part),
4916    )?;
4917    emit_durable_http(
4918        &st,
4919        &id,
4920        "message.part.updated",
4921        json!({"sessionID": id, "part": tool_part.clone()}),
4922    )?;
4923    st.bus.publish(status_frame("idle"));
4924
4925    Ok(Json(json!({"info": assistant_info, "parts": [tool_part]})))
4926}
4927
4928fn now_ms_local() -> i64 {
4929    std::time::SystemTime::now()
4930        .duration_since(std::time::UNIX_EPOCH)
4931        .map(|d| d.as_millis() as i64)
4932        .unwrap_or(0)
4933}
4934
4935/// Persist a message via the store + emit its updated/part durable events.
4936fn insert_message_http(
4937    writer: &ocserve_store::Writer,
4938    st: &Arc<AppState>,
4939    sid: &str,
4940    info: &Value,
4941    parts: &[Value],
4942) -> Result<(), ApiError> {
4943    ocserve_store::insert_message(writer, Some(&*st.blobs), sid, info, parts).map_err(|e| {
4944        ApiError {
4945            status: StatusCode::INTERNAL_SERVER_ERROR,
4946            name: "InternalError",
4947            message: format!("{e:#}"),
4948        }
4949    })
4950}
4951
4952/// Durable event from HTTP handlers (no PromptContext): persist + plain +
4953/// sync twin (seq from next_event_seq — single-flight per handler call).
4954/// Full session wire object merged with `overrides`, for a `session.updated`
4955/// event body. Falls back to `{id}` on a DB error so the caller never panics;
4956/// the event-shape guard (rule 19) flags any missing required key.
4957async fn session_wire_or_partial_http(st: &Arc<AppState>, sid: &str, overrides: Value) -> Value {
4958    let sid_owned = sid.to_string();
4959    run_blocking(&st.db, move |db| {
4960        ocserve_store::session_wire_with(db, &sid_owned, overrides)
4961    })
4962    .await
4963    .ok()
4964    .flatten()
4965    .unwrap_or_else(|| json!({"id": sid}))
4966}
4967pub(crate) fn emit_durable_http(
4968    st: &Arc<AppState>,
4969    sid: &str,
4970    event_type: &str,
4971    props: Value,
4972) -> Result<(), ApiError> {
4973    let props = ocserve_core::event::normalize_props(event_type, props);
4974    ocserve_store::append_event(&st.writer, Some(sid), event_type, &props).map_err(|e| {
4975        ApiError {
4976            status: StatusCode::INTERNAL_SERVER_ERROR,
4977            name: "InternalError",
4978            message: format!("{e:#}"),
4979        }
4980    })?;
4981    let seq = ocserve_store::next_event_seq(&st.db, sid).unwrap_or(0);
4982    let dir = st.paths["directory"].as_str().unwrap_or("/");
4983    st.bus
4984        .publish(ocserve_core::event::frame(dir, event_type, props.clone()));
4985    st.bus.publish(ocserve_core::event::sync_frame(
4986        dir, event_type, props, seq as u64, sid,
4987    ));
4988    Ok(())
4989}
4990
4991/// Freeze-shaped event id: `evt_` + 26 hex chars (upstream ids are time+random
4992/// 26-char alphanumerics; ours hash a nanos counter — clients only require
4993/// uniqueness, and byte-golden normalizes this span).
4994fn evt_id(kind: &str) -> String {
4995    use sha2::{Digest, Sha256};
4996    let nanos = std::time::SystemTime::now()
4997        .duration_since(std::time::UNIX_EPOCH)
4998        .map(|d| d.as_nanos())
4999        .unwrap_or(0);
5000    let mut h = Sha256::new();
5001    h.update(nanos.to_le_bytes());
5002    h.update(kind.as_bytes());
5003    let hex = hex::encode(h.finalize());
5004    format!("evt_{}", &hex[..26])
5005}
5006
5007/// SSE endpoint. Freeze facts (byte-golden capture, PLAN §3):
5008/// - response headers: Cache-Control: no-cache, no-transform; X-Content-Type-Options: nosniff;
5009///   x-accel-buffering: no; Vary: Origin; chunked transfer
5010/// - NO `id:` lines, NO `retry:` lines
5011/// - first frame: server.connected WITHOUT directory/project wrapper
5012/// - then 10s JSON heartbeats
5013/// - frames wrapped: {"directory":..,"project":..,"payload":..} (except server.connected)
5014pub(crate) async fn global_event(State(st): State<Arc<AppState>>) -> Response {
5015    event_stream(st, EventFormat::Global).await
5016}
5017
5018/// GET /event — v1 bare `Event` stream (`{id,type,properties}`, no wrapper).
5019/// The old wiring served the /global/event `{payload}` envelope here — a real
5020/// wire divergence (freeze /event: keys id/type/properties, no payload).
5021async fn bare_event(State(st): State<Arc<AppState>>) -> Response {
5022    event_stream(st, EventFormat::Bare).await
5023}
5024
5025/// GET /api/event — v2 `V2Event` stream (`{id,type,data}`, properties folded
5026/// into `data`; `data` is the frozen V2 field name).
5027async fn v2_event(State(st): State<Arc<AppState>>) -> Response {
5028    event_stream(st, EventFormat::V2).await
5029}
5030
5031#[derive(Clone, Copy)]
5032enum EventFormat {
5033    /// `{directory,project,payload:{id,type,properties}}`
5034    Global,
5035    /// `{id,type,properties}`
5036    Bare,
5037    /// `{id,type,data}`
5038    V2,
5039}
5040
5041/// Rewrite one stored global-event frame into the requested wire format.
5042/// Returns None for frames the format does not carry (the sync twin is a
5043/// /global/event-only concern; /event and /api/event drop it — freeze emits
5044/// neither there).
5045fn encode_event_frame(stored: &str, fmt: EventFormat) -> Option<String> {
5046    if matches!(fmt, EventFormat::Global) {
5047        return Some(stored.to_string());
5048    }
5049    let v: Value = serde_json::from_str(stored).ok()?;
5050    let payload = v.get("payload")?;
5051    if payload.get("type").and_then(Value::as_str) == Some("sync") {
5052        return None;
5053    }
5054    let id = payload.get("id").cloned().unwrap_or(Value::Null);
5055    let ty = payload.get("type").cloned().unwrap_or(Value::Null);
5056    let props = payload.get("properties").cloned().unwrap_or(json!({}));
5057    Some(match fmt {
5058        EventFormat::Bare => json!({"id": id, "type": ty, "properties": props}).to_string(),
5059        EventFormat::V2 => json!({"id": id, "type": ty, "data": props}).to_string(),
5060        EventFormat::Global => unreachable!(),
5061    })
5062}
5063
5064/// Build a synthetic `server.connected` / `server.heartbeat` frame in the given
5065/// format (freeze emits these with the format's own envelope: /global/event is
5066/// `{payload:{...}}` with NO directory/project on the connected frame, /event is
5067/// bare, /api/event is `{id,type,data}`).
5068fn synthetic_frame(fmt: EventFormat, event_type: &str) -> String {
5069    let id = evt_id(if event_type == "server.connected" {
5070        "connected"
5071    } else {
5072        "heartbeat"
5073    });
5074    match fmt {
5075        EventFormat::Global => json!({"payload": {
5076            "id": id, "type": event_type, "properties": {}}})
5077        .to_string(),
5078        EventFormat::Bare => json!({
5079            "id": id, "type": event_type, "properties": {}})
5080        .to_string(),
5081        EventFormat::V2 => json!({
5082            "id": id, "type": event_type, "data": {}})
5083        .to_string(),
5084    }
5085}
5086
5087/// Shared SSE body for /event, /global/event, /api/event.
5088async fn event_stream(st: Arc<AppState>, fmt: EventFormat) -> Response {
5089    let connected = Event::default().data(synthetic_frame(fmt, "server.connected"));
5090    let rx = st.bus.subscribe();
5091    ocserve_metrics::gauge(
5092        "ocserve_sse_clients",
5093        st.bus.subscriber_count().max(1) as i64,
5094    );
5095    // The stream must own a bus clone: if the Router/state were dropped while
5096    // a subscriber still streams (oneshot tests), the Sender must survive.
5097    let bus_keepalive = st.bus.clone();
5098    // Drop-guard: decrement the client gauge when the stream ends (any exit).
5099    struct SseClientGuard;
5100    impl Drop for SseClientGuard {
5101        fn drop(&mut self) {
5102            ocserve_metrics::gauge_delta("ocserve_sse_clients", -1);
5103        }
5104    }
5105    let sse_guard = SseClientGuard;
5106    // Timeout-driven merge: heartbeat at exact 10s cadence regardless of
5107    // traffic; bus frames yielded as they arrive; Lagged/Closed ends the
5108    // stream (bounded-queue disconnect, PLAN §4).
5109    let events = stream::once(async move { Ok::<_, std::convert::Infallible>(connected) }).chain(
5110        stream::unfold(
5111            (
5112                rx,
5113                tokio::time::Instant::now() + Duration::from_secs(10),
5114                bus_keepalive,
5115                sse_guard,
5116                fmt,
5117            ),
5118            |(mut rx, mut next_hb, bus, guard, fmt)| async move {
5119                loop {
5120                    let dur = next_hb.saturating_duration_since(tokio::time::Instant::now());
5121                    match tokio::time::timeout(dur.max(Duration::from_millis(1)), rx.recv()).await {
5122                        Ok(Ok(frame)) => {
5123                            ocserve_metrics::counter("ocserve_sse_events_total", 1);
5124                            // frames this format does not carry (the sync twin on
5125                            // /event and /api/event) are skipped, not emitted as
5126                            // empty frames (freeze has no such frame).
5127                            if let Some(out) = encode_event_frame(&frame, fmt) {
5128                                return Some((
5129                                    Ok(Event::default().data(&out)),
5130                                    (rx, next_hb, bus, guard, fmt),
5131                                ));
5132                            }
5133                        }
5134                        Ok(Err(_lagged_or_closed)) => {
5135                            ocserve_metrics::labeled_counter(
5136                                "ocserve_event_ring_lag_total",
5137                                "reason=\"lagged_or_closed\"",
5138                                1,
5139                            );
5140                            return None;
5141                        }
5142                        Err(_elapsed) => {
5143                            let hb =
5144                                Event::default().data(synthetic_frame(fmt, "server.heartbeat"));
5145                            next_hb += Duration::from_secs(10);
5146                            return Some((Ok(hb), (rx, next_hb, bus, guard, fmt)));
5147                        }
5148                    }
5149                }
5150            },
5151        ),
5152    );
5153    // No axum KeepAlive: the freeze stream carries zero comment/retry frames
5154    // (PLAN §3 byte-golden); the 10s heartbeat payload is the only activity.
5155    let mut resp = Sse::new(events).into_response();
5156    // Freeze header set — axum's defaults differ (no-transform / nosniff / accel).
5157    let h = resp.headers_mut();
5158    for (k, v) in sse_expected_headers() {
5159        if let (Ok(name), Ok(val)) = (
5160            axum::http::HeaderName::try_from(k),
5161            HeaderValue::from_str(v),
5162        ) {
5163            h.insert(name, val);
5164        }
5165    }
5166    resp
5167}
5168
5169/// Basic auth middleware (N6/PLAN §3: auth mode is a freeze artifact — live
5170/// instance is passwordless, so default is off; `basic` mode 401s everything
5171/// without matching credentials, including SSE and /metrics).
5172/// Per-request INFO logging (route hit-set capture) — opt-in via
5173/// OCSERVE_REQ_LOG=1 (read once at first request). Default off: at the
5174/// measured 10k+ rps a log line per request is pure journald amplification.
5175fn req_log() -> bool {
5176    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
5177    *ON.get_or_init(|| {
5178        std::env::var("OCSERVE_REQ_LOG")
5179            .map(|v| v == "1")
5180            .unwrap_or(false)
5181    })
5182}
5183
5184async fn auth_gate(
5185    State(st): State<Arc<AppState>>,
5186    req: axum::extract::Request,
5187    next: axum::middleware::Next,
5188) -> Result<Response, ApiError> {
5189    st.requests
5190        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5191    // SRE §2: request metrics (bounded route labels; measured at both exits)
5192    ocserve_metrics::counter("ocserve_requests_total", 1);
5193    let metric_t0 = std::time::Instant::now();
5194    let metric_label = format!(
5195        "route=\"{}\",method=\"{}\"",
5196        ocserve_metrics::route_label(req.uri().path()),
5197        req.method()
5198    );
5199    let record = move |t0: std::time::Instant| {
5200        ocserve_metrics::observe(
5201            "ocserve_http_request_duration_seconds",
5202            &metric_label,
5203            t0.elapsed().as_micros() as u64,
5204        );
5205    };
5206    // Hit-set capture (M1 §PLAN C4): route-inventory probes set
5207    // OCSERVE_REQ_LOG=1. Default OFF — one INFO line per request is pure
5208    // journald write amplification at the measured 10k+ rps (and the live
5209    // journal showed 1400 /api/health lines in hours of idle traffic).
5210    if req_log() {
5211        tracing::info!("req {} {}", req.method(), req.uri().path());
5212    }
5213    if let Some((user, pass)) = &st.auth {
5214        let authorized = req
5215            .headers()
5216            .get(axum::http::header::AUTHORIZATION)
5217            .and_then(|v| v.to_str().ok())
5218            .and_then(|v| v.strip_prefix("Basic "))
5219            .and_then(|b64| {
5220                use base64::Engine;
5221                base64::engine::general_purpose::STANDARD.decode(b64).ok()
5222            })
5223            .map(|raw| {
5224                let text = String::from_utf8_lossy(&raw);
5225                text == format!("{user}:{pass}")
5226            })
5227            .unwrap_or(false);
5228        if !authorized {
5229            record(metric_t0);
5230            return Err(ApiError {
5231                status: StatusCode::UNAUTHORIZED,
5232                name: "UnauthorizedError",
5233                message: "unauthorized".into(),
5234            });
5235        }
5236    }
5237    let resp = next.run(req).await;
5238    record(metric_t0);
5239    Ok(resp)
5240}
5241
5242/// Router for the P0 surface. Auth is an AppState decision (freeze §3).
5243pub fn router(state: Arc<AppState>) -> Router {
5244    Router::new()
5245        .route("/global/health", get(health))
5246        .route("/doc", get(get_doc))
5247        .route("/config", get(get_config))
5248        .route("/config/providers", get(get_config_providers))
5249        .route("/provider", get(get_provider))
5250        .route("/agent", get(get_agent))
5251        .route("/command", get(get_command))
5252        .route("/experimental/console", get(get_console))
5253        .route("/experimental/tool", get(get_experimental_tool))
5254        .route("/experimental/tool/ids", get(get_experimental_tool_ids))
5255        .route("/experimental/capabilities", get(get_capabilities))
5256        .route("/path", get(get_path))
5257        .route("/project", get(get_projects))
5258        .route("/project/current", get(get_project_current))
5259        .route("/session", get(get_sessions).post(post_session))
5260        .route("/session/{id}/fork", axum::routing::post(post_fork))
5261        .route("/session/status", get(session_status))
5262        .route("/session/search", axum::routing::post(search_messages))
5263        .route("/mcp/{name}/{action}", axum::routing::post(mcp_action))
5264        .route("/mcp/{name}/auth", axum::routing::delete(mcp_auth_remove))
5265        .route(
5266            "/auth/{providerID}",
5267            axum::routing::put(auth_put).delete(auth_delete),
5268        )
5269        .route("/provider/auth", axum::routing::get(provider_auth_methods))
5270        .route("/global/dispose", axum::routing::post(global_dispose))
5271        .route("/config", axum::routing::patch(patch_config))
5272        .route("/global/config", axum::routing::patch(patch_config))
5273        .route(
5274            "/session/{id}/summarize",
5275            axum::routing::post(post_summarize),
5276        )
5277        .route(
5278            "/session/{id}",
5279            get(get_session)
5280                .patch(patch_session)
5281                .delete(delete_session_route),
5282        )
5283        .route(
5284            "/session/{id}/message",
5285            get(get_messages).post(post_message),
5286        )
5287        .route("/global/event", get(global_event))
5288        // Distinct frozen shapes (live-probed 2026-10-11): /global/event wraps
5289        // `{directory,project,payload:{...}}`; /event is bare `{id,type,properties}`;
5290        // /api/event is v2 `{id,type,data}`. Serving one shape from all three was
5291        // a real wire divergence.
5292        .route("/event", get(bare_event))
5293        .route("/api/event", get(v2_event))
5294        .route("/metrics", get(metrics))
5295        // TUI-attach probes (captured live; PLAN §2 hit-set expansion).
5296        // The /api/* routes are registered in the v2 block below.
5297        .route("/experimental/resource", get(experimental_resource))
5298        .route(
5299            "/experimental/workspace",
5300            get(experimental_workspace).post(v2::workspace_create),
5301        )
5302        // TUI attach calls this at boot (observed live 2026-10-08); freeze
5303        // answers [] with 200 under an empty workspace state.
5304        .route(
5305            "/experimental/workspace/status",
5306            get(experimental_workspace_status),
5307        )
5308        .route("/formatter", get(formatter_list))
5309        .route("/lsp", get(lsp_list))
5310        .route("/project/{id}/directories", get(project_directories))
5311        .route("/vcs", get(vcs_info))
5312        .route("/vcs/status", get(vcs_status))
5313        .route("/vcs/diff", get(vcs_diff))
5314        .route("/vcs/diff/raw", get(vcs_diff_raw))
5315        .route("/file/status", get(file_status))
5316        .route("/find/symbol", get(find_symbol))
5317        .route("/global/config", get(global_config_get))
5318        .route(
5319            "/session/{id}/prompt_async",
5320            axum::routing::post(post_prompt_async),
5321        )
5322        .route("/session/{id}/children", get(get_children))
5323        .route("/file", get(list_directory))
5324        .route("/file/content", get(read_file_content))
5325        .route("/find/file", get(find_files))
5326        .route("/find", get(find_text))
5327        .route("/question", get(get_questions))
5328        .route(
5329            "/question/{id}/reply",
5330            axum::routing::post(post_question_reply),
5331        )
5332        .route(
5333            "/question/{id}/reject",
5334            axum::routing::post(post_question_reject),
5335        )
5336        .route("/session/{id}/todo", get(get_todos))
5337        .route("/session/{id}/abort", axum::routing::post(post_abort))
5338        .route("/session/{id}/command", axum::routing::post(post_command))
5339        .route("/session/{id}/shell", axum::routing::post(post_shell))
5340        .route(
5341            "/session/{id}/message/{mid}",
5342            get(get_message_by_id).delete(delete_message_route),
5343        )
5344        // legacy permission respond (deprecated upstream; oc-remote uses
5345        // /permission/{id}/reply — this is the SDK/v1 shape)
5346        .route(
5347            "/session/{id}/permissions/{pid}",
5348            axum::routing::post(post_session_permission_respond),
5349        )
5350        // v1 control-plane log writer (web UI diagnostics)
5351        .route("/log", axum::routing::post(post_log))
5352        // revert/unrevert (SNAPSHOT.md): real file-restoring revert — the
5353        // snapshot engine restores pre-turn files, reverts tool patch parts,
5354        // computes the range diff, sets the marker. Busy → 409.
5355        .route("/session/{id}/revert", axum::routing::post(post_revert))
5356        .route("/session/{id}/unrevert", axum::routing::post(post_unrevert))
5357        // /instance/dispose — v1 single-instance dispose (freeze: true)
5358        .route("/instance/dispose", axum::routing::post(instance_dispose))
5359        // /session/{id}/init — built-in `init` command (guided AGENTS.md;
5360        // payload {messageID,providerID,modelID} — probed freeze)
5361        .route("/session/{id}/init", axum::routing::post(post_init))
5362        // provider OAuth: ocserve implements no OAuth flows; the route
5363        // exists and answers the freeze shape for non-OAuth providers
5364        // (400 BadRequest {}) instead of falling through to the SPA.
5365        .route(
5366            "/provider/{id}/oauth/authorize",
5367            axum::routing::post(post_provider_oauth),
5368        )
5369        .route(
5370            "/provider/{id}/oauth/callback",
5371            axum::routing::post(post_provider_oauth_callback),
5372        )
5373        // MCP OAuth: tagged shapes probed (unknown → 404
5374        // McpServerNotFoundError; known/non-OAuth → 400
5375        // McpUnsupportedOAuthError). No OAuth-capable servers in practice.
5376        .route(
5377            "/mcp/{name}/auth/authenticate",
5378            axum::routing::post(post_mcp_auth_authenticate),
5379        )
5380        .route(
5381            "/mcp/{name}/auth/callback",
5382            axum::routing::post(post_mcp_auth_callback),
5383        )
5384        .route(
5385            "/session/{id}/message/{mid}/part/{pid}",
5386            axum::routing::delete(delete_part_route).patch(patch_part_route),
5387        )
5388        .route("/experimental/session", get(get_experimental_sessions))
5389        // ---- /pty/* (web-UI terminal; freeze-probed 2026-10-08) ----
5390        .route("/pty/shells", get(pty::get_shells))
5391        .route("/pty", get(pty::list).post(pty::create))
5392        .route(
5393            "/pty/{id}",
5394            get(pty::get).put(pty::update).delete(pty::remove),
5395        )
5396        .route(
5397            "/pty/{id}/connect-token",
5398            axum::routing::post(pty::connect_token),
5399        )
5400        .route("/pty/{id}/connect", get(pty::connect))
5401        // /tui/* — external-controller ingress (PLAN §17; VS Code extension)
5402        .route(
5403            "/tui/append-prompt",
5404            axum::routing::post(tui::append_prompt),
5405        )
5406        .route("/tui/open-help", axum::routing::post(tui::open_help))
5407        .route(
5408            "/tui/open-sessions",
5409            axum::routing::post(tui::open_sessions),
5410        )
5411        .route("/tui/open-themes", axum::routing::post(tui::open_themes))
5412        .route("/tui/open-models", axum::routing::post(tui::open_models))
5413        .route(
5414            "/tui/submit-prompt",
5415            axum::routing::post(tui::submit_prompt),
5416        )
5417        .route("/tui/clear-prompt", axum::routing::post(tui::clear_prompt))
5418        .route(
5419            "/tui/execute-command",
5420            axum::routing::post(tui::execute_command),
5421        )
5422        .route("/tui/show-toast", axum::routing::post(tui::show_toast))
5423        .route("/tui/publish", axum::routing::post(tui::publish_event))
5424        .route(
5425            "/tui/select-session",
5426            axum::routing::post(tui::select_session),
5427        )
5428        .route("/tui/control/next", get(tui::control_next))
5429        .route(
5430            "/tui/control/response",
5431            axum::routing::post(tui::control_response),
5432        )
5433        .route("/mcp", get(get_mcp).post(post_mcp))
5434        .route("/permission", get(get_permissions))
5435        .route(
5436            "/permission/{id}/reply",
5437            axum::routing::post(post_permission_reply),
5438        )
5439        // ---- v2 `/api/*` surface (1.18.31 TUI + web UI DataProvider) ----
5440        // Location-scoped collections: {location, data}.
5441        .route("/api/health", get(v2::health))
5442        .route("/api/location", get(v2::location))
5443        .route("/api/agent", get(get_api_agent))
5444        .route("/api/model", get(v2::list_models))
5445        .route("/api/provider", get(v2::list_providers))
5446        .route("/api/provider/{providerID}", get(v2::get_provider))
5447        .route("/api/skill", get(v2::list_skills))
5448        .route("/skill", get(v2::list_skills_raw))
5449        .route("/vcs/apply", axum::routing::post(v2::vcs_apply))
5450        .route("/api/command", get(get_api_command))
5451        .route("/api/integration", get(v2::list_integrations))
5452        .route("/api/integration/{integrationID}", get(v2::get_integration))
5453        .route("/api/reference", get(v2::list_references))
5454        .route("/api/fs/find", get(v2::fs_find))
5455        .route("/api/fs/list", get(v2::fs_list))
5456        .route("/api/fs/read/{*path}", get(v2::fs_read))
5457        // Sessions.
5458        .route(
5459            "/api/session",
5460            get(v2::list_sessions).post(v2::create_session),
5461        )
5462        .route("/api/session/active", get(v2::active_sessions))
5463        .route("/api/session/{sessionID}", get(v2::get_session))
5464        .route("/api/session/{sessionID}/message", get(v2::list_messages))
5465        .route(
5466            "/api/session/{sessionID}/message/{messageID}",
5467            get(v2::get_message),
5468        )
5469        .route("/api/session/{sessionID}/context", get(v2::session_context))
5470        .route("/api/session/{sessionID}/history", get(v2::session_history))
5471        .route(
5472            "/api/session/{sessionID}/agent",
5473            axum::routing::post(v2::switch_agent),
5474        )
5475        .route(
5476            "/api/session/{sessionID}/model",
5477            axum::routing::post(v2::switch_model),
5478        )
5479        .route(
5480            "/api/session/{sessionID}/prompt",
5481            axum::routing::post(v2::prompt),
5482        )
5483        .route(
5484            "/api/session/{sessionID}/interrupt",
5485            axum::routing::post(v2::interrupt),
5486        )
5487        .route(
5488            "/api/session/{sessionID}/compact",
5489            axum::routing::post(v2::compact),
5490        )
5491        .route(
5492            "/api/session/{sessionID}/wait",
5493            axum::routing::post(v2::wait),
5494        )
5495        .route(
5496            "/api/session/{sessionID}/revert/stage",
5497            axum::routing::post(v2::revert_stage),
5498        )
5499        .route(
5500            "/api/session/{sessionID}/revert/clear",
5501            axum::routing::post(v2::revert_clear),
5502        )
5503        .route(
5504            "/api/session/{sessionID}/revert/commit",
5505            axum::routing::post(v2::revert_commit),
5506        )
5507        .route(
5508            "/api/session/{sessionID}/permission/{requestID}/reply",
5509            axum::routing::post(v2::permission_reply),
5510        )
5511        .route(
5512            "/api/session/{sessionID}/question/{requestID}/reply",
5513            axum::routing::post(v2::question_reply),
5514        )
5515        .route(
5516            "/api/session/{sessionID}/question/{requestID}/reject",
5517            axum::routing::post(v2::question_reject),
5518        )
5519        // Permissions / questions.
5520        .route("/api/permission/saved", get(v2::permission_saved))
5521        .route(
5522            "/api/permission/saved/{id}",
5523            axum::routing::delete(v2::permission_saved_delete),
5524        )
5525        .route("/api/permission/request", get(v2::permission_request))
5526        .route("/api/question/request", get(v2::question_request))
5527        .route(
5528            "/api/session/{sessionID}/question",
5529            get(v2::session_question),
5530        )
5531        .route(
5532            "/api/session/{sessionID}/permission/{requestID}",
5533            get(v2::session_permission_one),
5534        )
5535        .route(
5536            "/api/session/{sessionID}/permission",
5537            get(v2::session_permission).post(v2::permission_create),
5538        )
5539        // pty under /api (same implementation).
5540        .route("/api/pty", get(v2::pty_list).post(v2::pty_create))
5541        .route(
5542            "/api/pty/{ptyID}",
5543            get(v2::pty_get).put(v2::pty_update).delete(v2::pty_remove),
5544        )
5545        .route(
5546            "/api/pty/{ptyID}/connect-token",
5547            axum::routing::post(v2::pty_connect_token),
5548        )
5549        .route("/api/pty/{ptyID}/connect", get(pty::connect))
5550        // v1 path the TUI session view calls (SidebarFiles crash path).
5551        .route("/session/{sessionID}/diff", get(v2::session_diff))
5552        .route(
5553            "/session/{sessionID}/share",
5554            axum::routing::post(v2::session_share).delete(v2::session_unshare),
5555        )
5556        // Experimental action stubs (freeze-probed shapes).
5557        .route("/experimental/console/orgs", get(v2::console_orgs))
5558        .route(
5559            "/experimental/console/switch",
5560            axum::routing::post(v2::console_switch),
5561        )
5562        .route(
5563            "/experimental/workspace/adapter",
5564            get(v2::workspace_adapter),
5565        )
5566        .route(
5567            "/experimental/workspace/sync-list",
5568            axum::routing::post(v2::workspace_sync_list),
5569        )
5570        .route(
5571            "/experimental/workspace/warp",
5572            axum::routing::post(v2::workspace_warp),
5573        )
5574        .route(
5575            "/experimental/worktree",
5576            get(v2::worktree_list).post(v2::worktree_create),
5577        )
5578        .route(
5579            "/experimental/worktree",
5580            axum::routing::delete(v2::worktree_remove),
5581        )
5582        .route(
5583            "/experimental/worktree/reset",
5584            axum::routing::post(v2::worktree_reset),
5585        )
5586        .route("/api/session/{sessionID}/event", get(v2::session_event))
5587        .route(
5588            "/project/{projectID}",
5589            axum::routing::patch(v2::patch_project),
5590        )
5591        // Sync / integration / credential / upgrade (mostly cited or
5592        // no-backend; freeze-probed shapes).
5593        .route("/sync/start", axum::routing::post(v2::sync_start))
5594        .route("/sync/replay", axum::routing::post(v2::sync_replay))
5595        .route("/sync/history", axum::routing::post(v2::sync_history))
5596        .route("/sync/steal", axum::routing::post(v2::sync_steal))
5597        .route("/project/git/init", axum::routing::post(v2::git_init))
5598        .route("/global/upgrade", axum::routing::post(v2::global_upgrade))
5599        .route(
5600            "/api/integration/{integrationID}/connect/key",
5601            axum::routing::post(v2::integration_connect_key),
5602        )
5603        .route(
5604            "/api/integration/{integrationID}/connect/oauth",
5605            axum::routing::post(v2::integration_connect_oauth),
5606        )
5607        .route(
5608            "/api/integration/attempt/{attemptID}",
5609            get(v2::integration_attempt_get).delete(v2::integration_attempt_delete),
5610        )
5611        .route(
5612            "/api/integration/attempt/{attemptID}/complete",
5613            axum::routing::post(v2::integration_attempt_complete),
5614        )
5615        .route(
5616            "/api/credential/{credentialID}",
5617            axum::routing::patch(v2::credential_update).delete(v2::credential_remove),
5618        )
5619        .route(
5620            "/experimental/control-plane/move-session",
5621            axum::routing::post(v2::control_move_session),
5622        )
5623        .route(
5624            "/experimental/project/{projectID}/copy",
5625            axum::routing::post(v2::project_copy_create).delete(v2::project_copy_remove),
5626        )
5627        .route(
5628            "/experimental/project/{projectID}/copy/generate-name",
5629            axum::routing::post(v2::project_copy_generate_name),
5630        )
5631        .route(
5632            "/experimental/project/{projectID}/copy/refresh",
5633            axum::routing::post(v2::project_copy_refresh),
5634        )
5635        .route(
5636            "/experimental/session/{sessionID}/background",
5637            axum::routing::post(v2::session_background),
5638        )
5639        .route(
5640            "/experimental/workspace/{id}",
5641            axum::routing::delete(v2::workspace_delete),
5642        )
5643        .layer(axum::middleware::from_fn_with_state(
5644            state.clone(),
5645            auth_gate,
5646        ))
5647        // CORS (upstream cors.ts parity) sits OUTSIDE auth: preflights must
5648        // answer without credentials, exactly like upstream.
5649        .layer(axum::middleware::from_fn_with_state(
5650            state.clone(),
5651            crate::ui::cors_gate,
5652        ))
5653        // Catch-all: unmatched paths serve the web UI (embedded upstream-side
5654        // or proxied to app.opencode.ai), matching v1 serveUIEffect. Disabled
5655        // via OCSERVE_UI=0 → JSON 404.
5656        .fallback(crate::ui::ui_fallback)
5657        // Upstream's catch-all is a *route*, not a fallback: a known path with
5658        // an unknown method (e.g. PATCH /pty/{id}) also gets the SPA, not a
5659        // 405. Probed against freeze 2026-10-08.
5660        .method_not_allowed_fallback(crate::ui::ui_fallback)
5661        .with_state(state)
5662}
5663
5664/// CORS extras: OCSERVE_CORS (comma-separated) — the `--cors` equivalent.
5665/// Config-file `server.cors` support arrives with the config passthrough;
5666/// env is the runtime-tunable equivalent until then.
5667fn read_cors_env() -> Vec<String> {
5668    std::env::var("OCSERVE_CORS")
5669        .ok()
5670        .map(|v| {
5671            v.split(',')
5672                .map(str::trim)
5673                .filter(|s| !s.is_empty())
5674                .map(str::to_string)
5675                .collect()
5676        })
5677        .unwrap_or_default()
5678}
5679
5680/// JSON key-path projection: `$`, `$.a`, `$.a[].b`, … (values ignored).
5681/// Shared oracle for keys-mode golden comparisons (manifest mode=keys).
5682pub fn keypaths(v: &serde_json::Value, prefix: &str, out: &mut std::collections::BTreeSet<String>) {
5683    match v {
5684        serde_json::Value::Object(m) => {
5685            for (k, val) in m {
5686                let p = format!("{prefix}.{k}");
5687                out.insert(p.clone());
5688                keypaths(val, &p, out);
5689            }
5690        }
5691        serde_json::Value::Array(a) => {
5692            let p = format!("{prefix}[]");
5693            if let Some(first) = a.first() {
5694                keypaths(first, &p, out);
5695            } else {
5696                out.insert(p);
5697            }
5698        }
5699        _ => {}
5700    }
5701}
5702
5703/// Header values the SSE route must carry (asserted by tests against golden bytes).
5704pub fn sse_expected_headers() -> Vec<(&'static str, &'static str)> {
5705    vec![
5706        ("cache-control", "no-cache, no-transform"),
5707        ("x-content-type-options", "nosniff"),
5708        ("content-type", "text/event-stream"),
5709        ("x-accel-buffering", "no"),
5710    ]
5711}
5712
5713#[allow(dead_code)]
5714fn _assert_header_value(v: &str) -> HeaderValue {
5715    HeaderValue::from_str(v).expect("static header value")
5716}
5717
5718#[cfg(test)]
5719mod limit_matrix {
5720    use super::parse_limit;
5721
5722    #[test]
5723    fn matches_probe_envelopes() {
5724        // Byte strings from message_page_contract.json probes (upstream 1.18.31)
5725        assert_eq!(
5726            parse_limit("abc").unwrap_err(),
5727            "Expected an integer, got NaN\n  at [\"limit\"]"
5728        );
5729        assert_eq!(
5730            parse_limit("5.5").unwrap_err(),
5731            "Expected an integer, got 5.5\n  at [\"limit\"]"
5732        );
5733        assert_eq!(
5734            parse_limit("-0.5").unwrap_err(),
5735            "Expected an integer, got -0.5\n  at [\"limit\"]"
5736        );
5737        assert_eq!(
5738            parse_limit("-1").unwrap_err(),
5739            "Expected a value greater than or equal to 0, got -1\n  at [\"limit\"]"
5740        );
5741        assert_eq!(
5742            parse_limit("999999999999999999999").unwrap_err(),
5743            "Expected an integer, got 1e+21\n  at [\"limit\"]"
5744        );
5745        assert_eq!(
5746            parse_limit("Infinity").unwrap_err(),
5747            "Expected an integer, got Infinity\n  at [\"limit\"]"
5748        );
5749    }
5750
5751    #[test]
5752    fn accepts_integers_like_js_number() {
5753        assert_eq!(parse_limit("+5"), Ok(5));
5754        assert_eq!(parse_limit("1e3"), Ok(1000)); // JS Number("1e3") = 1000
5755        assert_eq!(parse_limit("0"), Ok(0));
5756        assert_eq!(parse_limit("9007199254740991"), Ok(9007199254740991)); // 2^53-1
5757    }
5758}
5759
5760#[cfg(test)]
5761mod f1_blocking_tests {
5762    use super::{AppState, Payloads, router, run_blocking};
5763    use std::sync::Arc;
5764    use std::sync::atomic::{AtomicU64, Ordering};
5765    use std::time::Duration;
5766
5767    /// PERF-10X F1: a blocking store call must NOT park the async worker.
5768    /// Single-worker runtime makes this discriminating: with the call inlined
5769    /// (the pre-F1 shape) the ticker cannot advance while the call runs
5770    /// (after == before → red); via the blocking pool it keeps ticking.
5771    /// M3: `GET /find/file`'s directory walk must execute OFF the tokio
5772    /// worker. Structural proof via thread identity, not timing: a ticker-
5773    /// based version of this test passed with the walk still inline (a few
5774    /// thousand files walk in microseconds), so it proved nothing.
5775    ///
5776    /// The probe drives the REAL route — calling `walk_files` directly would
5777    /// bypass the very `spawn_blocking` under test, which the first version
5778    /// of this test did (and it failed for exactly that reason).
5779    #[tokio::test]
5780    async fn find_files_walk_runs_off_the_worker() {
5781        use tower::ServiceExt as _;
5782        let dir = std::env::temp_dir().join(format!(
5783            "ocserve-m3-walk-{}-{}",
5784            std::process::id(),
5785            std::time::SystemTime::now()
5786                .duration_since(std::time::UNIX_EPOCH)
5787                .unwrap()
5788                .as_nanos()
5789        ));
5790        std::fs::create_dir_all(&dir).unwrap();
5791        for i in 0..200 {
5792            std::fs::write(dir.join(format!("f{i}.txt")), b"x").unwrap();
5793        }
5794
5795        let mut payloads = Payloads::default();
5796        payloads.config["directory"] = serde_json::Value::String(dir.to_string_lossy().to_string());
5797        let st = AppState::with_payloads(None, payloads);
5798
5799        let handler_thread = std::thread::current().id();
5800        let req = axum::http::Request::builder()
5801            .uri("/find/file?query=f1&limit=10")
5802            .body(axum::body::Body::empty())
5803            .unwrap();
5804        let resp = tokio::spawn(async move { router(st).oneshot(req).await })
5805            .await
5806            .expect("route task")
5807            .expect("infallible");
5808        assert_eq!(resp.status(), 200);
5809
5810        let walked_on = super::WALK_THREAD.lock().ok().and_then(|g| *g);
5811        let walked_on = walked_on.expect("walk_files must record its thread");
5812        assert_ne!(
5813            walked_on, handler_thread,
5814            "the walk ran on the async worker — blocking syscalls must go \
5815             through spawn_blocking (M3)"
5816        );
5817        let _ = std::fs::remove_dir_all(&dir);
5818    }
5819
5820    /// PERF-10X F1: a blocking store call must NOT park the async worker.
5821    /// Single-worker runtime makes this discriminating: with the call inlined
5822    /// (the pre-F1 shape) the ticker cannot advance while the call runs
5823    /// (after == before → red); via the blocking pool it keeps ticking.
5824    /// Negative control: swap run_blocking for the inline sleep → red.
5825    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
5826
5827    async fn run_blocking_frees_the_worker() {
5828        let ticks = Arc::new(AtomicU64::new(0));
5829        let t = ticks.clone();
5830        let ticker = tokio::spawn(async move {
5831            let mut i = 0u64;
5832            loop {
5833                tokio::time::sleep(Duration::from_millis(10)).await;
5834                i += 1;
5835                t.store(i, Ordering::Relaxed);
5836            }
5837        });
5838        tokio::time::sleep(Duration::from_millis(30)).await;
5839        let before = ticks.load(Ordering::Relaxed);
5840        assert!(before > 0, "ticker must be running before the probe");
5841
5842        // The probe runs as a SPAWNED task: only spawned tasks execute on
5843        // worker threads (the #[tokio::test] body itself runs on block_on's
5844        // thread — an inline sleep there parked nothing and the first version
5845        // of this test passed its own negative control; caught 2026-10-06).
5846        let probe = tokio::spawn(async move {
5847            run_blocking(std::path::Path::new("/nonexistent/f1-probe"), |_db| {
5848                std::thread::sleep(Duration::from_millis(250));
5849                Ok::<(), anyhow::Error>(())
5850            })
5851            .await
5852            .expect("probe op");
5853        });
5854        probe.await.expect("probe join");
5855
5856        let after = ticks.load(Ordering::Relaxed);
5857        ticker.abort();
5858        assert!(
5859            after > before,
5860            "blocking call parked the worker: before={before} after={after}"
5861        );
5862    }
5863}
5864
5865#[cfg(test)]
5866mod f5_wire {
5867    use super::*;
5868
5869    /// Identity control: cached bytes == what axum's Json would produce
5870    /// (both serde_json::to_vec) — one value per key, no drift.
5871    #[test]
5872    fn wire_bytes_are_serde_identical() {
5873        let st = AppState::new();
5874        let p = st.payloads.read();
5875        let w = st.wire.read();
5876        for (k, v) in [
5877            ("config", &p.config),
5878            ("config_providers", &p.config_providers),
5879            ("provider", &p.provider),
5880            ("console", &p.console),
5881            ("capabilities", &p.capabilities),
5882        ] {
5883            let cached = w.get(k).unwrap_or_else(|| panic!("missing wire key {k}"));
5884            let want = serde_json::to_vec(v).unwrap();
5885            assert_eq!(
5886                &cached.identity[..],
5887                &want[..],
5888                "wire bytes for {k} must equal Value serialization"
5889            );
5890        }
5891        for (k, v) in [("agent", &p.agent), ("command", &p.command)] {
5892            let cached = w.get(k).unwrap_or_else(|| panic!("missing wire key {k}"));
5893            let want = serde_json::to_vec(v).unwrap();
5894            assert_eq!(&cached.identity[..], &want[..]);
5895        }
5896    }
5897
5898    /// Kill-switch: wire_off => no cache, wire_json falls back (None).
5899    #[test]
5900    fn wire_off_disables_cache() {
5901        let empty = rebuild_wire(
5902            &Payloads::default(),
5903            true,
5904            &json!({"directory": "/", "project": {"id": "global", "directory": "/"}}),
5905        );
5906        assert!(empty.is_empty(), "off => nothing serialized");
5907        let st = AppState::new();
5908        st.wire_off
5909            .store(true, std::sync::atomic::Ordering::Relaxed);
5910        assert!(wire_json(&st, "config").is_none(), "off => Value path");
5911    }
5912
5913    /// v2 metadata wire entries: cached bytes == the pure builder serialized
5914    /// (the single-source-of-truth parity — a handler drift from its builder
5915    /// would serve stale shapes to browsers with `Accept-Encoding`).
5916    #[test]
5917    fn v2_metadata_wire_matches_builders() {
5918        for (catalog, name) in [
5919            (
5920                serde_json::json!({"deepseek": {"name": "DeepSeek"}}),
5921                "with-catalog",
5922            ),
5923            (serde_json::json!({}), "empty-catalog"),
5924        ] {
5925            let payloads = Payloads {
5926                provider: serde_json::json!({
5927                    "connected": ["opencode", "deepseek"],
5928                    "all": [
5929                        {"id": "opencode", "name": "opencode", "models": {"m": {"id": "m", "providerID": "opencode", "name": "M"}}},
5930                        {"id": "deepseek", "name": "DeepSeek", "models": {"d": {"id": "d", "providerID": "deepseek", "name": "D"}}},
5931                    ]
5932                }),
5933                config: serde_json::json!({"provider": {"entrim": {"name": "Entrim"}}}),
5934                catalog: std::sync::Arc::new(catalog),
5935                ..Default::default()
5936            };
5937            let location = json!({"directory": "/", "project": {"id": "global", "directory": "/"}});
5938            let wire = rebuild_wire(&payloads, false, &location);
5939            for (key, want) in [
5940                ("api_model", crate::v2::models_body(&payloads, &location)),
5941                (
5942                    "api_provider",
5943                    crate::v2::providers_body(&payloads, &location),
5944                ),
5945                (
5946                    "api_integration",
5947                    crate::v2::integrations_body(&payloads, &location),
5948                ),
5949            ] {
5950                let e = wire
5951                    .get(key)
5952                    .unwrap_or_else(|| panic!("[{name}] missing wire key {key}"));
5953                assert_eq!(
5954                    &e.identity[..],
5955                    &serde_json::to_vec(&want).unwrap()[..],
5956                    "[{name}] {key} wire bytes must equal the pure builder"
5957                );
5958            }
5959        }
5960    }
5961}
5962
5963#[cfg(test)]
5964mod doc_contract {
5965    /// P0: `/doc` serves the vendored OpenAPI contract byte-for-byte; it must
5966    /// be valid JSON with the expected shape (162 paths / 188 ops). This is the
5967    /// oracle P4 validation and the P1 coverage guard read.
5968    #[test]
5969    fn spec_is_valid_and_complete() {
5970        let d: serde_json::Value =
5971            serde_json::from_str(crate::doc::SPEC_JSON).expect("vendored spec must be valid JSON");
5972        assert_eq!(d["openapi"], "3.1.0");
5973        let paths = d["paths"].as_object().expect("paths object");
5974        assert_eq!(paths.len(), 162, "frozen 1.18.31 has 162 paths");
5975        let ops: usize = paths
5976            .values()
5977            .map(|v| {
5978                v.as_object()
5979                    .unwrap()
5980                    .keys()
5981                    .filter(|k| matches!(k.as_str(), "get" | "post" | "put" | "delete" | "patch"))
5982                    .count()
5983            })
5984            .sum();
5985        assert_eq!(ops, 188, "frozen 1.18.31 has 188 operations");
5986        assert!(d["components"]["schemas"].as_object().unwrap().len() >= 400);
5987    }
5988}