Skip to main content

soma_studio_server/
app.rs

1mod embedded_assets {
2    include!(concat!(env!("OUT_DIR"), "/embedded_web_assets.rs"));
3}
4
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{Arc, RwLock};
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11use anyhow::{Context, Result};
12use hyper_util::rt::{TokioExecutor, TokioIo};
13use hyper_util::server::conn::auto::Builder as AutoBuilder;
14use ranvier::http::ingress::RawIngressService;
15use ranvier::http::{
16    CookieJar, HttpIngress, Ranvier, StaticAssetPolicy, StaticAssetSource, StaticShell,
17};
18use soma_studio_core::{
19    AppConfig, ProviderSummary, SourceRootSummary, WorkspaceFileChangePreviewRequest,
20    WorkspaceTaskRunRequest, WorkspaceTaskRunResponse, WorkspaceTaskRunStatus,
21    WorkspaceTaskRunSummary,
22};
23use tokio::net::TcpListener;
24use tokio::sync::broadcast;
25use tracing::{info, warn};
26use uuid::Uuid;
27
28use crate::chat_events::ChatEventEnvelope;
29use crate::storage::StudioStorage;
30use crate::transitions::{
31    RequestHost, RequestOrigin, RequestPath, bootstrap_redirect_axon, chat_send_axon,
32    conversation_delete_axon, conversation_messages_axon, conversations_create_axon,
33    conversations_list_axon, health_axon, ingest_jobs_axon, ingest_rescan_axon, ingest_status_axon,
34    init_axon, notebook_adapters_list_axon, notebook_artifact_axon, notebook_chunks_build_axon,
35    notebook_chunks_get_axon, notebook_embeddings_build_axon, notebook_embeddings_get_axon,
36    notebook_index_build_axon, notebook_index_get_axon, notebook_note_create_axon,
37    notebook_note_read_axon, notebook_note_render_axon, notebook_note_write_axon,
38    notebook_notes_list_axon, notebook_notes_search_axon, notebook_retrieve_axon,
39    provider_models_axon, provider_models_error_response, provider_selection_get_axon,
40    provider_selection_set_axon, provider_test_axon, providers_axon, search_axon,
41    search_index_rebuild_axon, search_index_recover_axon, search_index_status_axon,
42    search_index_sync_axon, search_open_action_axon, source_roots_create_axon,
43    source_roots_list_axon, stream_chat_axon, workspace_file_change_apply_axon,
44    workspace_file_change_audits_list_axon, workspace_file_change_preview_axon,
45    workspace_file_preview_axon, workspace_files_axon, workspace_source_root_create_axon,
46    workspace_task_run_axon, workspace_task_run_cancel_axon, workspace_task_run_get_axon,
47    workspace_task_run_start_axon, workspace_task_runs_list_axon,
48};
49use crate::workspace::WorkspaceError;
50use crate::workspace_tasks::WorkspaceTaskError;
51
52#[derive(Debug, Clone)]
53pub struct SessionInfo {
54    pub id: String,
55}
56
57#[derive(Debug, Clone)]
58pub struct BootstrapToken {
59    pub value: String,
60    pub expires_at: SystemTime,
61}
62
63#[derive(Debug, Clone)]
64pub struct AppContext {
65    pub config: AppConfig,
66    pub storage: StudioStorage,
67    pub bootstrap_tokens: Arc<RwLock<HashMap<String, BootstrapToken>>>,
68    pub sessions: Arc<RwLock<HashMap<String, SessionInfo>>>,
69    pub source_roots: Arc<RwLock<Vec<SourceRootSummary>>>,
70    pub workspace_task_runs: Arc<RwLock<HashMap<Uuid, WorkspaceTaskRunRecord>>>,
71    pub workspace_file_change_previews:
72        Arc<RwLock<HashMap<Uuid, WorkspaceFileChangePreviewRecord>>>,
73    pub chat_events: broadcast::Sender<ChatEventEnvelope>,
74}
75
76#[derive(Debug, Clone)]
77pub struct WorkspaceTaskRunRecord {
78    pub session_id: String,
79    pub summary: WorkspaceTaskRunSummary,
80    pub cancel_requested: Arc<AtomicBool>,
81}
82
83#[derive(Debug, Clone)]
84pub struct WorkspaceFileChangePreviewRecord {
85    pub session_id: String,
86    pub request: WorkspaceFileChangePreviewRequest,
87    pub base_modified_at_ms: Option<u64>,
88    pub base_size_bytes: Option<u64>,
89    pub expires_at_ms: u64,
90}
91
92const MAX_ACTIVE_WORKSPACE_TASK_RUNS_PER_SESSION: usize = 1;
93const WORKSPACE_TASK_RUN_RETENTION_MS: u64 = 30 * 60 * 1000;
94const WORKSPACE_FILE_CHANGE_PREVIEW_TTL_MS: u64 = 10 * 60 * 1000;
95const WORKSPACE_TASK_FAILED_EXIT_CODE: &str = "workspace_task_failed_exit";
96const WORKSPACE_TASK_TIMED_OUT_CODE: &str = "workspace_task_timed_out";
97
98impl AppContext {
99    pub fn new(config: AppConfig, storage: StudioStorage) -> Self {
100        let (chat_events, _) = broadcast::channel(128);
101        Self {
102            config,
103            storage,
104            bootstrap_tokens: Arc::new(RwLock::new(HashMap::new())),
105            sessions: Arc::new(RwLock::new(HashMap::new())),
106            source_roots: Arc::new(RwLock::new(Vec::new())),
107            workspace_task_runs: Arc::new(RwLock::new(HashMap::new())),
108            workspace_file_change_previews: Arc::new(RwLock::new(HashMap::new())),
109            chat_events,
110        }
111    }
112
113    pub fn issue_bootstrap_token(&self) -> String {
114        let token = Uuid::new_v4().to_string();
115        let entry = BootstrapToken {
116            value: token.clone(),
117            expires_at: SystemTime::now() + Duration::from_secs(300),
118        };
119        self.bootstrap_tokens
120            .write()
121            .expect("bootstrap token store poisoned")
122            .insert(token.clone(), entry);
123        token
124    }
125
126    pub fn consume_bootstrap_token(&self, token: &str) -> bool {
127        let mut store = self
128            .bootstrap_tokens
129            .write()
130            .expect("bootstrap token store poisoned");
131        let Some(entry) = store.remove(token) else {
132            return false;
133        };
134        entry.value == token && entry.expires_at > SystemTime::now()
135    }
136
137    pub async fn issue_session(&self) -> Result<SessionInfo> {
138        let session_id = Uuid::new_v4().to_string();
139        self.storage
140            .persist_session(&session_id)
141            .await
142            .with_context(|| format!("failed to persist issued session {session_id}"))?;
143        self.remember_session(session_id.clone())
144            .with_context(|| format!("generated session id should be valid UUID: {session_id}"))
145    }
146
147    pub fn lookup_session(&self, session_id: &str) -> Option<SessionInfo> {
148        self.sessions
149            .read()
150            .expect("session store poisoned")
151            .get(session_id)
152            .cloned()
153    }
154
155    pub fn remember_session(&self, session_id: impl Into<String>) -> Option<SessionInfo> {
156        let session_id = session_id.into();
157        if Uuid::parse_str(&session_id).is_err() {
158            return None;
159        }
160        if let Some(existing) = self.lookup_session(&session_id) {
161            return Some(existing);
162        }
163
164        let session = SessionInfo {
165            id: session_id.clone(),
166        };
167        self.sessions
168            .write()
169            .expect("session store poisoned")
170            .insert(session_id, session.clone());
171        Some(session)
172    }
173
174    pub fn provider_summaries(&self) -> Vec<ProviderSummary> {
175        vec![
176            ProviderSummary {
177                id: "ollama".to_string(),
178                label: "Ollama".to_string(),
179                endpoint_hint: "http://127.0.0.1:11434".to_string(),
180                last_test_ok: None,
181                last_test_detail: None,
182                last_tested_at: None,
183            },
184            ProviderSummary {
185                id: "lmstudio".to_string(),
186                label: "LM Studio".to_string(),
187                endpoint_hint: "http://127.0.0.1:1234".to_string(),
188                last_test_ok: None,
189                last_test_detail: None,
190                last_tested_at: None,
191            },
192        ]
193    }
194
195    pub fn register_source_root(&self, summary: SourceRootSummary) -> SourceRootSummary {
196        let mut roots = self
197            .source_roots
198            .write()
199            .expect("source root store poisoned");
200        if let Some(existing) = roots.iter().find(|existing| existing.path == summary.path) {
201            return existing.clone();
202        }
203        roots.push(summary.clone());
204        summary
205    }
206
207    pub fn publish_chat_event(&self, event: ChatEventEnvelope) {
208        let _ = self.chat_events.send(event);
209    }
210
211    pub fn create_workspace_task_run(
212        &self,
213        session_id: &str,
214        input: &WorkspaceTaskRunRequest,
215        path: String,
216    ) -> std::result::Result<(WorkspaceTaskRunSummary, Arc<AtomicBool>), WorkspaceTaskError> {
217        let mut runs = self
218            .workspace_task_runs
219            .write()
220            .expect("workspace task run store poisoned");
221        prune_finished_workspace_task_runs(&mut runs, current_time_ms());
222        let active_count = runs
223            .values()
224            .filter(|record| {
225                record.session_id == session_id
226                    && matches!(
227                        record.summary.status,
228                        WorkspaceTaskRunStatus::Queued | WorkspaceTaskRunStatus::Running
229                    )
230            })
231            .count();
232        if active_count >= MAX_ACTIVE_WORKSPACE_TASK_RUNS_PER_SESSION {
233            return Err(WorkspaceTaskError::busy(
234                "workspace task run must wait for the active run to finish",
235            ));
236        }
237
238        let run_id = Uuid::new_v4();
239        let cancel_requested = Arc::new(AtomicBool::new(false));
240        let summary = WorkspaceTaskRunSummary {
241            run_id,
242            task_id: input.task_id,
243            path,
244            status: WorkspaceTaskRunStatus::Running,
245            command_label: crate::workspace_tasks::workspace_task_command_label(input.task_id)
246                .to_string(),
247            exit_code: None,
248            stdout_tail: String::new(),
249            stderr_tail: String::new(),
250            stdout_truncated: false,
251            stderr_truncated: false,
252            timed_out: false,
253            cancel_requested: false,
254            started_at_ms: current_time_ms(),
255            completed_at_ms: None,
256            duration_ms: None,
257            error: None,
258            error_code: None,
259            max_output_bytes: crate::workspace_tasks::workspace_task_max_output_bytes(),
260        };
261        runs.insert(
262            run_id,
263            WorkspaceTaskRunRecord {
264                session_id: session_id.to_string(),
265                summary: summary.clone(),
266                cancel_requested: cancel_requested.clone(),
267            },
268        );
269        Ok((summary, cancel_requested))
270    }
271
272    pub fn list_workspace_task_runs(
273        &self,
274        session_id: &str,
275        error_code: Option<&str>,
276    ) -> Vec<WorkspaceTaskRunSummary> {
277        let mut runs = self
278            .workspace_task_runs
279            .write()
280            .expect("workspace task run store poisoned");
281        prune_finished_workspace_task_runs(&mut runs, current_time_ms());
282        let mut summaries: Vec<_> = runs
283            .values()
284            .filter(|record| {
285                record.session_id == session_id
286                    && error_code
287                        .is_none_or(|code| record.summary.error_code.as_deref() == Some(code))
288            })
289            .map(|record| record.summary.clone())
290            .collect();
291        summaries.sort_by_key(|summary| std::cmp::Reverse(summary.started_at_ms));
292        summaries
293    }
294
295    pub fn get_workspace_task_run(
296        &self,
297        session_id: &str,
298        run_id: Uuid,
299    ) -> Option<WorkspaceTaskRunSummary> {
300        let mut runs = self
301            .workspace_task_runs
302            .write()
303            .expect("workspace task run store poisoned");
304        prune_finished_workspace_task_runs(&mut runs, current_time_ms());
305        runs.get(&run_id)
306            .filter(|record| record.session_id == session_id)
307            .map(|record| record.summary.clone())
308    }
309
310    pub fn remove_workspace_task_run(&self, session_id: &str, run_id: Uuid) {
311        let mut runs = self
312            .workspace_task_runs
313            .write()
314            .expect("workspace task run store poisoned");
315        let Some(record) = runs.get(&run_id) else {
316            return;
317        };
318        if record.session_id == session_id {
319            runs.remove(&run_id);
320        }
321    }
322
323    pub fn cancel_workspace_task_run(
324        &self,
325        session_id: &str,
326        run_id: Uuid,
327    ) -> Option<WorkspaceTaskRunSummary> {
328        let mut runs = self
329            .workspace_task_runs
330            .write()
331            .expect("workspace task run store poisoned");
332        prune_finished_workspace_task_runs(&mut runs, current_time_ms());
333        let record = runs.get_mut(&run_id)?;
334        if record.session_id != session_id {
335            return None;
336        }
337        if matches!(
338            record.summary.status,
339            WorkspaceTaskRunStatus::Queued | WorkspaceTaskRunStatus::Running
340        ) {
341            record.cancel_requested.store(true, Ordering::SeqCst);
342            record.summary.cancel_requested = true;
343        }
344        Some(record.summary.clone())
345    }
346
347    pub fn finish_workspace_task_run(
348        &self,
349        session_id: &str,
350        run_id: Uuid,
351        result: std::result::Result<WorkspaceTaskRunResponse, WorkspaceTaskError>,
352    ) -> Option<WorkspaceTaskRunSummary> {
353        let mut runs = self
354            .workspace_task_runs
355            .write()
356            .expect("workspace task run store poisoned");
357        let record = runs.get_mut(&run_id)?;
358        if record.session_id != session_id {
359            return None;
360        }
361        let completed_at_ms = current_time_ms();
362        match result {
363            Ok(response) => {
364                let failed_exit = !matches!(response.exit_code, Some(0));
365                record.summary.status = if response.cancelled {
366                    WorkspaceTaskRunStatus::Cancelled
367                } else if response.timed_out {
368                    WorkspaceTaskRunStatus::TimedOut
369                } else if failed_exit {
370                    WorkspaceTaskRunStatus::Failed
371                } else {
372                    WorkspaceTaskRunStatus::Complete
373                };
374                record.summary.path = response.path;
375                record.summary.command_label = response.command_label;
376                record.summary.exit_code = response.exit_code;
377                record.summary.stdout_tail = response.stdout;
378                record.summary.stderr_tail = response.stderr;
379                record.summary.stdout_truncated = response.stdout_truncated;
380                record.summary.stderr_truncated = response.stderr_truncated;
381                record.summary.timed_out = response.timed_out;
382                record.summary.cancel_requested =
383                    record.summary.cancel_requested || response.cancelled;
384                record.summary.completed_at_ms = Some(completed_at_ms);
385                record.summary.duration_ms = Some(response.duration_ms);
386                let (error, error_code) = if response.cancelled {
387                    (None, None)
388                } else if response.timed_out {
389                    (
390                        Some("workspace task timed out".to_string()),
391                        Some(WORKSPACE_TASK_TIMED_OUT_CODE.to_string()),
392                    )
393                } else if failed_exit {
394                    (
395                        Some(match response.exit_code {
396                            Some(code) => format!("workspace task exited with code {code}"),
397                            None => "workspace task ended without an exit code".to_string(),
398                        }),
399                        Some(WORKSPACE_TASK_FAILED_EXIT_CODE.to_string()),
400                    )
401                } else {
402                    (None, None)
403                };
404                record.summary.error = error;
405                record.summary.error_code = error_code;
406                record.summary.max_output_bytes = response.max_output_bytes;
407            }
408            Err(error) => {
409                record.summary.status = WorkspaceTaskRunStatus::Failed;
410                record.summary.completed_at_ms = Some(completed_at_ms);
411                record.summary.duration_ms =
412                    Some(completed_at_ms.saturating_sub(record.summary.started_at_ms));
413                record.summary.error_code = Some(error.api_code().to_string());
414                record.summary.error = Some(error.to_string());
415            }
416        }
417        Some(record.summary.clone())
418    }
419
420    pub fn register_workspace_file_change_preview(
421        &self,
422        session_id: &str,
423        preview_token: Uuid,
424        expires_at_ms: u64,
425        request: WorkspaceFileChangePreviewRequest,
426        base_modified_at_ms: Option<u64>,
427        base_size_bytes: Option<u64>,
428    ) {
429        let mut previews = self
430            .workspace_file_change_previews
431            .write()
432            .expect("workspace file change preview store poisoned");
433        prune_workspace_file_change_previews(&mut previews, current_time_ms());
434        previews.insert(
435            preview_token,
436            WorkspaceFileChangePreviewRecord {
437                session_id: session_id.to_string(),
438                request,
439                base_modified_at_ms,
440                base_size_bytes,
441                expires_at_ms,
442            },
443        );
444    }
445
446    pub fn new_workspace_file_change_preview_token(&self) -> (Uuid, u64) {
447        (
448            Uuid::new_v4(),
449            current_time_ms().saturating_add(WORKSPACE_FILE_CHANGE_PREVIEW_TTL_MS),
450        )
451    }
452
453    pub fn consume_workspace_file_change_preview(
454        &self,
455        session_id: &str,
456        preview_token: Uuid,
457    ) -> std::result::Result<WorkspaceFileChangePreviewRecord, WorkspaceError> {
458        let mut previews = self
459            .workspace_file_change_previews
460            .write()
461            .expect("workspace file change preview store poisoned");
462        prune_workspace_file_change_previews(&mut previews, current_time_ms());
463        let Some(record) = previews.remove(&preview_token) else {
464            return Err(WorkspaceError::preview_missing(
465                "workspace file change preview missing or stale",
466            ));
467        };
468        if record.session_id != session_id {
469            previews.insert(preview_token, record);
470            return Err(WorkspaceError::preview_missing(
471                "workspace file change preview missing or stale",
472            ));
473        }
474        Ok(record)
475    }
476}
477
478fn current_time_ms() -> u64 {
479    SystemTime::now()
480        .duration_since(UNIX_EPOCH)
481        .map(|duration| duration.as_millis() as u64)
482        .unwrap_or(0)
483}
484
485fn prune_finished_workspace_task_runs(
486    runs: &mut HashMap<Uuid, WorkspaceTaskRunRecord>,
487    now_ms: u64,
488) {
489    runs.retain(|_, record| {
490        if matches!(
491            record.summary.status,
492            WorkspaceTaskRunStatus::Queued | WorkspaceTaskRunStatus::Running
493        ) {
494            return true;
495        }
496        let Some(completed_at_ms) = record.summary.completed_at_ms else {
497            return true;
498        };
499        now_ms.saturating_sub(completed_at_ms) <= WORKSPACE_TASK_RUN_RETENTION_MS
500    });
501}
502
503fn prune_workspace_file_change_previews(
504    previews: &mut HashMap<Uuid, WorkspaceFileChangePreviewRecord>,
505    now_ms: u64,
506) {
507    previews.retain(|_, record| record.expires_at_ms > now_ms);
508}
509
510pub struct PreparedServer {
511    config: AppConfig,
512    bootstrap_url: String,
513    listener: TcpListener,
514    raw_service: RawIngressService<()>,
515}
516
517impl PreparedServer {
518    pub fn bootstrap_url(&self) -> &str {
519        &self.bootstrap_url
520    }
521
522    pub async fn run(self) -> Result<()> {
523        let bind_addr = self.config.bind_addr.clone();
524        let listener = self.listener;
525        let raw_service = self.raw_service;
526
527        info!("starting Soma Studio server on {}", bind_addr);
528
529        loop {
530            tokio::select! {
531                accept_result = listener.accept() => {
532                    let (stream, _) = accept_result.context("failed to accept TCP connection")?;
533                    let io = TokioIo::new(stream);
534                    let service = raw_service.clone();
535                    tokio::spawn(async move {
536                        let builder = AutoBuilder::new(TokioExecutor::new());
537                        if let Err(error) = builder.serve_connection(io, service).await {
538                            warn!("connection handling failed: {error}");
539                        }
540                    });
541                }
542                signal = tokio::signal::ctrl_c() => {
543                    if let Err(error) = signal {
544                        warn!("failed to listen for Ctrl+C: {error}");
545                    }
546                    break;
547                }
548            }
549        }
550
551        Ok(())
552    }
553}
554
555pub async fn prepare_server(mut config: AppConfig) -> Result<PreparedServer> {
556    config.ensure_directories()?;
557    if !config.web_shell_file.exists()
558        && let Some(extracted_dir) = ensure_embedded_web_assets(&config)?
559    {
560        config = config.with_web_build_dir(extracted_dir);
561    }
562
563    if !config.web_shell_file.exists() {
564        anyhow::bail!(
565            "web shell not found at {}. Set SOMA_STUDIO_WEB_DIR or build the web app first.",
566            config.web_shell_file.display()
567        );
568    }
569
570    let bind_addr = config.bind_socket_addr()?;
571    let listener = TcpListener::bind(bind_addr)
572        .await
573        .with_context(|| format!("failed to bind {}", config.bind_addr))?;
574    let local_addr = listener
575        .local_addr()
576        .context("failed to inspect bound local address")?;
577    config = config.with_bind_addr(local_addr.to_string());
578
579    let storage = StudioStorage::open(&config).await?;
580    let persisted_source_roots = storage.list_source_roots().await?;
581    let context = AppContext::new(config.clone(), storage);
582    *context
583        .source_roots
584        .write()
585        .expect("source root store poisoned") = persisted_source_roots;
586    let bootstrap_token = context.issue_bootstrap_token();
587    let bootstrap_url = format!(
588        "http://{}/bootstrap?token={}",
589        config.bind_addr, bootstrap_token
590    );
591    let raw_service = build_raw_service(&context);
592
593    Ok(PreparedServer {
594        config,
595        bootstrap_url,
596        listener,
597        raw_service,
598    })
599}
600
601pub fn embedded_web_asset_count() -> usize {
602    embedded_assets::EMBEDDED_WEB_ASSETS.len()
603}
604
605pub fn embedded_web_shell_available() -> bool {
606    embedded_assets::EMBEDDED_WEB_ASSETS
607        .iter()
608        .any(|asset| asset.path == "spa.html")
609}
610
611fn build_raw_service(context: &AppContext) -> RawIngressService<()> {
612    build_ingress(context).into_raw_service(())
613}
614
615fn build_ingress(context: &AppContext) -> HttpIngress<()> {
616    let build_dir = context.config.web_build_dir.clone();
617    let shell_file = context.config.web_shell_file.clone();
618    let user_assets_dir = context.config.user_assets_dir.clone();
619    let app_context = context.clone();
620
621    Ranvier::http::<()>()
622        .bus_injector(move |parts, bus| {
623            let origin = parts
624                .headers
625                .get("origin")
626                .and_then(|value| value.to_str().ok())
627                .map(|value| value.to_string());
628            let host = parts
629                .headers
630                .get("host")
631                .and_then(|value| value.to_str().ok())
632                .map(|value| value.to_string());
633            let path = parts.uri.path().to_string();
634            bus.insert(app_context.clone());
635            bus.insert(CookieJar::from_parts(parts));
636            bus.insert(RequestOrigin(origin));
637            bus.insert(RequestHost(host));
638            bus.insert(RequestPath(path));
639        })
640        .serve_assets(
641            "/assets",
642            StaticAssetSource::directory(user_assets_dir.to_string_lossy().to_string()),
643            StaticAssetPolicy::public_assets()
644                .compression()
645                .enable_range_requests(),
646        )
647        .serve_assets(
648            "/",
649            StaticAssetSource::directory(build_dir.to_string_lossy().to_string()),
650            StaticAssetPolicy::public_assets()
651                .compression()
652                .serve_precompressed()
653                .enable_range_requests()
654                .directory_index("index.html"),
655        )
656        .serve_spa_shell(
657            StaticShell::file(shell_file.to_string_lossy().to_string())
658                .cache_control("no-store")
659                .compression()
660                .exclude_prefix("/api")
661                .exclude_prefix("/assets")
662                .exclude_prefix("/bootstrap"),
663        )
664        .get("/bootstrap", bootstrap_redirect_axon())
665        .get_json_out("/api/health", health_axon())
666        .get_with_error(
667            "/api/providers/models",
668            provider_models_axon(),
669            provider_models_error_response,
670        )
671        .group("/api", |g| {
672            g.guard(crate::transitions::RequireSessionGuard)
673                .get_json_out("/app/init", init_axon())
674                .get_json_out("/providers", providers_axon())
675                .get_json_out("/providers/selection", provider_selection_get_axon())
676                .get_json_out("/source-roots", source_roots_list_axon())
677                .get("/workspace/files", workspace_files_axon())
678                .get("/workspace/file-preview", workspace_file_preview_axon())
679                .get_json_out(
680                    "/workspace/file-change-audits",
681                    workspace_file_change_audits_list_axon(),
682                )
683                .get_json_out("/workspace/task-runs", workspace_task_runs_list_axon())
684                .get("/workspace/task-runs/:id", workspace_task_run_get_axon())
685                .get_json_out("/ingest/jobs", ingest_jobs_axon())
686                .get_json_out("/ingest/status", ingest_status_axon())
687                .get("/search", search_axon())
688                .get_json_out("/search/index/status", search_index_status_axon())
689                .get_json_out("/search/open-action", search_open_action_axon())
690                .get_json_out("/conversations", conversations_list_axon())
691                .get_json_out("/conversations/:id/messages", conversation_messages_axon())
692                .get("/notebook/tree", notebook_notes_list_axon())
693                .get("/notebook/note", notebook_note_read_axon())
694                .get("/notebook/search", notebook_notes_search_axon())
695                .get("/notebook/adapters", notebook_adapters_list_axon())
696                .get("/notebook/index", notebook_index_get_axon())
697                .get("/notebook/chunks", notebook_chunks_get_axon())
698                .get("/notebook/embeddings", notebook_embeddings_get_axon())
699                .get("/notebook/retrieve", notebook_retrieve_axon())
700                .get("/notebook/artifact", notebook_artifact_axon())
701                .get("/stream/chat", stream_chat_axon())
702                .group("", |g| {
703                    g.guard(crate::transitions::RequireSameOriginGuard)
704                        .post_typed("/providers/selection", provider_selection_set_axon())
705                        .post_typed("/source-roots", source_roots_create_axon())
706                        .post_typed(
707                            "/workspace/source-root",
708                            workspace_source_root_create_axon(),
709                        )
710                        .post_typed(
711                            "/workspace/file-change-preview",
712                            workspace_file_change_preview_axon(),
713                        )
714                        .post_typed(
715                            "/workspace/file-change-apply",
716                            workspace_file_change_apply_axon(),
717                        )
718                        .post_typed("/workspace/task", workspace_task_run_axon())
719                        .post_typed("/workspace/task-runs", workspace_task_run_start_axon())
720                        .post(
721                            "/workspace/task-runs/:id/cancel",
722                            workspace_task_run_cancel_axon(),
723                        )
724                        .post_typed("/ingest/rescan", ingest_rescan_axon())
725                        .post("/search/index/rebuild", search_index_rebuild_axon())
726                        .post("/search/index/sync", search_index_sync_axon())
727                        .post("/search/index/recover", search_index_recover_axon())
728                        .post_typed_json_out("/conversations", conversations_create_axon())
729                        .delete_json_out("/conversations/:id", conversation_delete_axon())
730                        .post_typed("/notebook/note", notebook_note_create_axon())
731                        .put_typed("/notebook/note", notebook_note_write_axon())
732                        .post_typed("/notebook/render", notebook_note_render_axon())
733                        .post("/notebook/index", notebook_index_build_axon())
734                        .post("/notebook/chunks", notebook_chunks_build_axon())
735                        .post("/notebook/embeddings", notebook_embeddings_build_axon())
736                        .post_typed_json_out("/chat/send", chat_send_axon())
737                        .post_typed("/providers/test", provider_test_axon())
738                })
739        })
740}
741
742fn ensure_embedded_web_assets(config: &AppConfig) -> Result<Option<PathBuf>> {
743    if embedded_assets::EMBEDDED_WEB_ASSETS.is_empty() {
744        return Ok(None);
745    }
746
747    let runtime_web_dir = config.data_dir.join("runtime-web");
748    for asset in embedded_assets::EMBEDDED_WEB_ASSETS {
749        let target = runtime_web_dir.join(asset.path);
750        if let Some(parent) = target.parent() {
751            std::fs::create_dir_all(parent)
752                .with_context(|| format!("failed to create {}", parent.display()))?;
753        }
754        std::fs::write(&target, asset.bytes)
755            .with_context(|| format!("failed to write {}", target.display()))?;
756    }
757
758    Ok(Some(runtime_web_dir))
759}
760
761#[cfg(test)]
762mod tests {
763    use crate::storage::StudioStorage;
764    use http::StatusCode;
765    use ranvier::http::{TestApp, TestRequest};
766    use serde_json::json;
767    use soma_studio_core::AppConfig;
768    use soma_studio_core::{
769        ApiErrorResponse, ChatSendResponse, ConversationDeleteResponse, ConversationMessage,
770        ConversationSummary, IngestJobSummary, IngestStatusResponse, NotebookAdapterStatus,
771        NotebookChunkResponse, NotebookIndexResponse, NotebookNoteContent, NotebookNoteFormat,
772        NotebookNoteSummary, NotebookRenderResponse, NotebookRetrievalResponse,
773        NotebookSearchResult, ProviderSelectionResponse, ProviderTestResponse, SearchFieldScope,
774        SearchIndexRebuildResponse, SearchIndexStatusResponse, SearchOpenActionResponse,
775        SearchResponse, SearchSort, SearchSourceType, WorkspaceEntryKind,
776        WorkspaceFileChangeAction, WorkspaceFileChangeApplyResponse, WorkspaceFileChangeAuditEntry,
777        WorkspaceFileChangeAuditStatus, WorkspaceFileChangePreviewRequest,
778        WorkspaceFileChangePreviewResponse, WorkspaceFileListResponse,
779        WorkspaceFilePreviewResponse, WorkspaceTaskRunResponse, WorkspaceTaskRunStatus,
780        WorkspaceTaskRunSummary,
781    };
782    use uuid::Uuid;
783
784    use super::{AppContext, SourceRootSummary, build_ingress};
785
786    #[test]
787    fn bootstrap_token_is_single_use() {
788        let config = AppConfig::from_env().expect("config");
789        let runtime = tokio::runtime::Runtime::new().expect("runtime");
790        let storage = runtime.block_on(async {
791            let temp_dir =
792                std::env::temp_dir().join(format!("soma-studio-test-{}", Uuid::new_v4()));
793            std::fs::create_dir_all(&temp_dir).expect("temp dir");
794            let config = AppConfig {
795                app_name: "Soma Studio".to_string(),
796                bind_addr: "127.0.0.1:0".to_string(),
797                project_root: temp_dir.clone(),
798                data_dir: temp_dir.clone(),
799                derived_dir: temp_dir.join("derived"),
800                notebook_dir: temp_dir.join("notebook"),
801                user_assets_dir: temp_dir.join("assets"),
802                db_path: temp_dir.join("test.db"),
803                web_build_dir: temp_dir.join("web"),
804                web_shell_file: temp_dir.join("web").join("spa.html"),
805            };
806            StudioStorage::open(&config).await.expect("storage")
807        });
808        let context = AppContext::new(config, storage);
809        let token = context.issue_bootstrap_token();
810
811        assert!(context.consume_bootstrap_token(&token));
812        assert!(!context.consume_bootstrap_token(&token));
813    }
814
815    #[tokio::test]
816    async fn bootstrap_route_sets_persistent_session_cookie() {
817        let temp_dir =
818            std::env::temp_dir().join(format!("soma-studio-bootstrap-{}", Uuid::new_v4()));
819        std::fs::create_dir_all(&temp_dir).expect("temp dir");
820        let config = AppConfig {
821            app_name: "Soma Studio".to_string(),
822            bind_addr: "127.0.0.1:0".to_string(),
823            project_root: temp_dir.clone(),
824            data_dir: temp_dir.clone(),
825            derived_dir: temp_dir.join("derived"),
826            notebook_dir: temp_dir.join("notebook"),
827            user_assets_dir: temp_dir.join("assets"),
828            db_path: temp_dir.join("test.db"),
829            web_build_dir: temp_dir.join("web"),
830            web_shell_file: temp_dir.join("web").join("spa.html"),
831        };
832        let storage = StudioStorage::open(&config).await.expect("storage");
833        let context = AppContext::new(config, storage);
834        let token = context.issue_bootstrap_token();
835        let app = TestApp::new(build_ingress(&context), ());
836
837        let response = app
838            .send(TestRequest::get(format!("/bootstrap?token={token}")))
839            .await
840            .expect("bootstrap response");
841
842        assert_eq!(response.status(), StatusCode::FOUND);
843        assert_eq!(
844            response
845                .header("location")
846                .and_then(|value| value.to_str().ok()),
847            Some("/")
848        );
849        let set_cookie = response
850            .header("set-cookie")
851            .and_then(|value| value.to_str().ok())
852            .expect("set-cookie");
853        assert!(set_cookie.contains("soma_studio_session="));
854        assert!(set_cookie.contains("Max-Age=2592000"));
855        assert!(set_cookie.contains("HttpOnly"));
856    }
857
858    #[test]
859    fn register_source_root_returns_existing_summary_for_duplicates() {
860        let config = AppConfig::from_env().expect("config");
861        let runtime = tokio::runtime::Runtime::new().expect("runtime");
862        let storage = runtime.block_on(async {
863            let temp_dir =
864                std::env::temp_dir().join(format!("soma-studio-test-{}", Uuid::new_v4()));
865            std::fs::create_dir_all(&temp_dir).expect("temp dir");
866            let config = AppConfig {
867                app_name: "Soma Studio".to_string(),
868                bind_addr: "127.0.0.1:0".to_string(),
869                project_root: temp_dir.clone(),
870                data_dir: temp_dir.clone(),
871                derived_dir: temp_dir.join("derived"),
872                notebook_dir: temp_dir.join("notebook"),
873                user_assets_dir: temp_dir.join("assets"),
874                db_path: temp_dir.join("test.db"),
875                web_build_dir: temp_dir.join("web"),
876                web_shell_file: temp_dir.join("web").join("spa.html"),
877            };
878            StudioStorage::open(&config).await.expect("storage")
879        });
880        let context = AppContext::new(config, storage);
881        let first = SourceRootSummary {
882            id: Uuid::new_v4(),
883            path: "F:/docs".to_string(),
884            read_only: true,
885        };
886        let second = SourceRootSummary {
887            id: Uuid::new_v4(),
888            path: "F:/docs".to_string(),
889            read_only: true,
890        };
891
892        let inserted = context.register_source_root(first.clone());
893        let duplicate = context.register_source_root(second);
894
895        assert_eq!(inserted.id, duplicate.id);
896        assert_eq!(context.source_roots.read().expect("source roots").len(), 1);
897    }
898
899    #[test]
900    fn remember_session_rehydrates_valid_uuid() {
901        let config = AppConfig::from_env().expect("config");
902        let runtime = tokio::runtime::Runtime::new().expect("runtime");
903        let storage = runtime.block_on(async {
904            let temp_dir =
905                std::env::temp_dir().join(format!("soma-studio-test-{}", Uuid::new_v4()));
906            std::fs::create_dir_all(&temp_dir).expect("temp dir");
907            let config = AppConfig {
908                app_name: "Soma Studio".to_string(),
909                bind_addr: "127.0.0.1:0".to_string(),
910                project_root: temp_dir.clone(),
911                data_dir: temp_dir.clone(),
912                derived_dir: temp_dir.join("derived"),
913                notebook_dir: temp_dir.join("notebook"),
914                user_assets_dir: temp_dir.join("assets"),
915                db_path: temp_dir.join("test.db"),
916                web_build_dir: temp_dir.join("web"),
917                web_shell_file: temp_dir.join("web").join("spa.html"),
918            };
919            StudioStorage::open(&config).await.expect("storage")
920        });
921        let context = AppContext::new(config, storage);
922        let session_id = Uuid::new_v4().to_string();
923
924        assert!(context.lookup_session(&session_id).is_none());
925        let restored = context
926            .remember_session(session_id.clone())
927            .expect("valid session id");
928
929        assert_eq!(restored.id, session_id);
930        assert_eq!(
931            context
932                .lookup_session(&session_id)
933                .expect("session should exist")
934                .id,
935            session_id
936        );
937    }
938
939    #[test]
940    fn remember_session_rejects_invalid_identifier() {
941        let config = AppConfig::from_env().expect("config");
942        let runtime = tokio::runtime::Runtime::new().expect("runtime");
943        let storage = runtime.block_on(async {
944            let temp_dir =
945                std::env::temp_dir().join(format!("soma-studio-test-{}", Uuid::new_v4()));
946            std::fs::create_dir_all(&temp_dir).expect("temp dir");
947            let config = AppConfig {
948                app_name: "Soma Studio".to_string(),
949                bind_addr: "127.0.0.1:0".to_string(),
950                project_root: temp_dir.clone(),
951                data_dir: temp_dir.clone(),
952                derived_dir: temp_dir.join("derived"),
953                notebook_dir: temp_dir.join("notebook"),
954                user_assets_dir: temp_dir.join("assets"),
955                db_path: temp_dir.join("test.db"),
956                web_build_dir: temp_dir.join("web"),
957                web_shell_file: temp_dir.join("web").join("spa.html"),
958            };
959            StudioStorage::open(&config).await.expect("storage")
960        });
961        let context = AppContext::new(config, storage);
962
963        assert!(context.remember_session("not-a-uuid").is_none());
964        assert!(context.lookup_session("not-a-uuid").is_none());
965    }
966
967    #[test]
968    fn workspace_file_change_preview_token_survives_wrong_session_attempt() {
969        let config = AppConfig::from_env().expect("config");
970        let runtime = tokio::runtime::Runtime::new().expect("runtime");
971        let storage = runtime.block_on(async {
972            let temp_dir =
973                std::env::temp_dir().join(format!("soma-studio-test-{}", Uuid::new_v4()));
974            std::fs::create_dir_all(&temp_dir).expect("temp dir");
975            let config = AppConfig {
976                app_name: "Soma Studio".to_string(),
977                bind_addr: "127.0.0.1:0".to_string(),
978                project_root: temp_dir.clone(),
979                data_dir: temp_dir.clone(),
980                derived_dir: temp_dir.join("derived"),
981                notebook_dir: temp_dir.join("notebook"),
982                user_assets_dir: temp_dir.join("assets"),
983                db_path: temp_dir.join("test.db"),
984                web_build_dir: temp_dir.join("web"),
985                web_shell_file: temp_dir.join("web").join("spa.html"),
986            };
987            StudioStorage::open(&config).await.expect("storage")
988        });
989        let context = AppContext::new(config, storage);
990        let session_id = Uuid::new_v4().to_string();
991        let other_session_id = Uuid::new_v4().to_string();
992        let (preview_token, expires_at_ms) = context.new_workspace_file_change_preview_token();
993        let request = WorkspaceFileChangePreviewRequest {
994            action: WorkspaceFileChangeAction::WriteText,
995            path: "docs/new.md".to_string(),
996            target_path: None,
997            content: Some("# New\n".to_string()),
998            expected_modified_at_ms: None,
999        };
1000
1001        context.register_workspace_file_change_preview(
1002            &session_id,
1003            preview_token,
1004            expires_at_ms,
1005            request,
1006            None,
1007            None,
1008        );
1009
1010        assert!(
1011            context
1012                .consume_workspace_file_change_preview(&other_session_id, preview_token)
1013                .is_err()
1014        );
1015        let record = context
1016            .consume_workspace_file_change_preview(&session_id, preview_token)
1017            .expect("original session should still own the preview");
1018
1019        assert_eq!(record.session_id, session_id);
1020    }
1021
1022    #[tokio::test]
1023    async fn workspace_files_route_lists_project_root_with_escape_guard() {
1024        let temp_dir =
1025            std::env::temp_dir().join(format!("soma-studio-workspace-http-{}", Uuid::new_v4()));
1026        std::fs::create_dir_all(temp_dir.join("docs")).expect("docs dir");
1027        std::fs::create_dir_all(temp_dir.join("src")).expect("src dir");
1028        std::fs::write(temp_dir.join("README.md"), "# Readme").expect("readme");
1029        std::fs::write(temp_dir.join("docs").join("guide.md"), "# Guide").expect("guide");
1030        std::fs::write(
1031            temp_dir.join("Cargo.toml"),
1032            "[package]\nname = \"soma-studio-route-test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1033        )
1034        .expect("cargo manifest");
1035        std::fs::write(
1036            temp_dir.join("src").join("lib.rs"),
1037            "pub fn ok() -> bool { true }\n",
1038        )
1039        .expect("cargo lib");
1040        let git_init = std::process::Command::new("git")
1041            .arg("init")
1042            .arg("--quiet")
1043            .current_dir(&temp_dir)
1044            .output()
1045            .expect("run git init");
1046        assert!(
1047            git_init.status.success(),
1048            "git init failed: {}",
1049            String::from_utf8_lossy(&git_init.stderr)
1050        );
1051        let config = AppConfig {
1052            app_name: "Soma Studio".to_string(),
1053            bind_addr: "127.0.0.1:0".to_string(),
1054            project_root: temp_dir.clone(),
1055            data_dir: temp_dir.clone(),
1056            derived_dir: temp_dir.join("derived"),
1057            notebook_dir: temp_dir.join("notebook"),
1058            user_assets_dir: temp_dir.join("assets"),
1059            db_path: temp_dir.join("test.db"),
1060            web_build_dir: temp_dir.join("web"),
1061            web_shell_file: temp_dir.join("web").join("spa.html"),
1062        };
1063        let storage = StudioStorage::open(&config).await.expect("storage");
1064        let context = AppContext::new(config, storage);
1065        let session = context.issue_session().await.expect("session");
1066        let cookie = format!("soma_studio_session={}", session.id);
1067        let app = TestApp::new(build_ingress(&context), ());
1068
1069        let root_response = app
1070            .send(TestRequest::get("/api/workspace/files").header("cookie", &cookie))
1071            .await
1072            .expect("workspace root response");
1073        assert_eq!(root_response.status(), StatusCode::OK);
1074        let root: WorkspaceFileListResponse = root_response.json().expect("workspace root payload");
1075        assert_eq!(root.path, "");
1076        assert_eq!(root.parent_path, None);
1077        assert!(
1078            root.entries
1079                .iter()
1080                .any(|entry| entry.name == "docs" && entry.kind == WorkspaceEntryKind::Directory)
1081        );
1082        assert!(
1083            root.entries
1084                .iter()
1085                .any(|entry| entry.name == "README.md" && entry.kind == WorkspaceEntryKind::File)
1086        );
1087
1088        let docs_response = app
1089            .send(TestRequest::get("/api/workspace/files?path=docs").header("cookie", &cookie))
1090            .await
1091            .expect("workspace docs response");
1092        assert_eq!(docs_response.status(), StatusCode::OK);
1093        let docs: WorkspaceFileListResponse = docs_response.json().expect("workspace docs payload");
1094        assert_eq!(docs.path, "docs");
1095        assert_eq!(docs.parent_path.as_deref(), Some(""));
1096        assert_eq!(docs.entries.len(), 1);
1097        assert_eq!(docs.entries[0].path, "docs/guide.md");
1098
1099        let preview_response = app
1100            .send(
1101                TestRequest::get("/api/workspace/file-preview?path=docs%2Fguide.md")
1102                    .header("cookie", &cookie),
1103            )
1104            .await
1105            .expect("workspace preview response");
1106        assert_eq!(preview_response.status(), StatusCode::OK);
1107        let preview: WorkspaceFilePreviewResponse =
1108            preview_response.json().expect("workspace preview payload");
1109        assert_eq!(preview.path, "docs/guide.md");
1110        assert_eq!(preview.content, "# Guide");
1111        assert!(!preview.truncated);
1112
1113        let change_preview_response = app
1114            .send(
1115                TestRequest::post("/api/workspace/file-change-preview")
1116                    .header("cookie", &cookie)
1117                    .header("origin", "http://test.local")
1118                    .json(&json!({
1119                        "action": "write_text",
1120                        "path": "docs/new.md",
1121                        "content": "# New\n",
1122                        "expected_modified_at_ms": null
1123                    }))
1124                    .expect("workspace file change preview request"),
1125            )
1126            .await
1127            .expect("workspace file change preview response");
1128        assert_eq!(change_preview_response.status(), StatusCode::OK);
1129        let change_preview: WorkspaceFileChangePreviewResponse = change_preview_response
1130            .json()
1131            .expect("workspace file change preview payload");
1132        assert_eq!(change_preview.path, "docs/new.md");
1133        assert!(!change_preview.exists_before);
1134        assert_eq!(change_preview.size_bytes_after, Some(6));
1135        assert!(change_preview.diff_preview.contains("+# New"));
1136        assert!(!temp_dir.join("docs").join("new.md").exists());
1137
1138        let change_apply_response = app
1139            .send(
1140                TestRequest::post("/api/workspace/file-change-apply")
1141                    .header("cookie", &cookie)
1142                    .header("origin", "http://test.local")
1143                    .json(&json!({ "preview_token": change_preview.preview_token }))
1144                    .expect("workspace file change apply request"),
1145            )
1146            .await
1147            .expect("workspace file change apply response");
1148        assert_eq!(change_apply_response.status(), StatusCode::OK);
1149        let change_apply: WorkspaceFileChangeApplyResponse = change_apply_response
1150            .json()
1151            .expect("workspace file change apply payload");
1152        assert!(change_apply.applied);
1153        assert!(change_apply.exists_after);
1154        assert_eq!(
1155            std::fs::read_to_string(temp_dir.join("docs").join("new.md")).expect("new file"),
1156            "# New\n"
1157        );
1158
1159        let stale_apply_response = app
1160            .send(
1161                TestRequest::post("/api/workspace/file-change-apply")
1162                    .header("cookie", &cookie)
1163                    .header("origin", "http://test.local")
1164                    .json(&json!({ "preview_token": change_preview.preview_token }))
1165                    .expect("stale workspace file change apply request"),
1166            )
1167            .await
1168            .expect("stale workspace file change apply response");
1169        assert_eq!(stale_apply_response.status(), StatusCode::NOT_FOUND);
1170        let stale_apply_error: ApiErrorResponse = stale_apply_response
1171            .json()
1172            .expect("stale workspace file change apply error");
1173        assert_eq!(
1174            stale_apply_error.code,
1175            "workspace_file_change_preview_missing"
1176        );
1177        assert_eq!(stale_apply_error.status, StatusCode::NOT_FOUND.as_u16());
1178
1179        let rename_preview_response = app
1180            .send(
1181                TestRequest::post("/api/workspace/file-change-preview")
1182                    .header("cookie", &cookie)
1183                    .header("origin", "http://test.local")
1184                    .json(&json!({
1185                        "action": "rename_path",
1186                        "path": "docs/new.md",
1187                        "target_path": "docs/renamed.md",
1188                        "content": null,
1189                        "expected_modified_at_ms": change_apply.modified_at_ms_after
1190                    }))
1191                    .expect("workspace file rename preview request"),
1192            )
1193            .await
1194            .expect("workspace file rename preview response");
1195        assert_eq!(rename_preview_response.status(), StatusCode::OK);
1196        let rename_preview: WorkspaceFileChangePreviewResponse = rename_preview_response
1197            .json()
1198            .expect("workspace file rename preview payload");
1199        assert_eq!(rename_preview.path, "docs/new.md");
1200        assert_eq!(
1201            rename_preview.target_path.as_deref(),
1202            Some("docs/renamed.md")
1203        );
1204        assert_eq!(rename_preview.size_bytes_after, Some(6));
1205        assert!(
1206            rename_preview
1207                .diff_preview
1208                .contains("rename from docs/new.md")
1209        );
1210        assert!(
1211            rename_preview
1212                .diff_preview
1213                .contains("rename to docs/renamed.md")
1214        );
1215        assert!(!temp_dir.join("docs").join("renamed.md").exists());
1216
1217        std::fs::write(temp_dir.join("docs").join("renamed.md"), "# Existing\n")
1218            .expect("race rename target");
1219        let rename_race_apply_response = app
1220            .send(
1221                TestRequest::post("/api/workspace/file-change-apply")
1222                    .header("cookie", &cookie)
1223                    .header("origin", "http://test.local")
1224                    .json(&json!({ "preview_token": rename_preview.preview_token }))
1225                    .expect("workspace file rename race apply request"),
1226            )
1227            .await
1228            .expect("workspace file rename race apply response");
1229        assert_eq!(rename_race_apply_response.status(), StatusCode::CONFLICT);
1230        let rename_race_error: ApiErrorResponse = rename_race_apply_response
1231            .json()
1232            .expect("workspace file rename race error");
1233        assert_eq!(rename_race_error.code, "workspace_conflict");
1234        assert_eq!(rename_race_error.status, StatusCode::CONFLICT.as_u16());
1235        assert_eq!(
1236            std::fs::read_to_string(temp_dir.join("docs").join("new.md")).expect("new file"),
1237            "# New\n"
1238        );
1239        assert_eq!(
1240            std::fs::read_to_string(temp_dir.join("docs").join("renamed.md"))
1241                .expect("existing rename target"),
1242            "# Existing\n"
1243        );
1244        std::fs::remove_file(temp_dir.join("docs").join("renamed.md"))
1245            .expect("remove race rename target");
1246
1247        let rename_preview_response = app
1248            .send(
1249                TestRequest::post("/api/workspace/file-change-preview")
1250                    .header("cookie", &cookie)
1251                    .header("origin", "http://test.local")
1252                    .json(&json!({
1253                        "action": "rename_path",
1254                        "path": "docs/new.md",
1255                        "target_path": "docs/renamed.md",
1256                        "content": null,
1257                        "expected_modified_at_ms": change_apply.modified_at_ms_after
1258                    }))
1259                    .expect("workspace file rename preview request"),
1260            )
1261            .await
1262            .expect("workspace file rename preview response");
1263        assert_eq!(rename_preview_response.status(), StatusCode::OK);
1264        let rename_preview: WorkspaceFileChangePreviewResponse = rename_preview_response
1265            .json()
1266            .expect("workspace file rename preview payload");
1267
1268        let rename_apply_response = app
1269            .send(
1270                TestRequest::post("/api/workspace/file-change-apply")
1271                    .header("cookie", &cookie)
1272                    .header("origin", "http://test.local")
1273                    .json(&json!({ "preview_token": rename_preview.preview_token }))
1274                    .expect("workspace file rename apply request"),
1275            )
1276            .await
1277            .expect("workspace file rename apply response");
1278        assert_eq!(rename_apply_response.status(), StatusCode::OK);
1279        let rename_apply: WorkspaceFileChangeApplyResponse = rename_apply_response
1280            .json()
1281            .expect("workspace file rename apply payload");
1282        assert!(rename_apply.applied);
1283        assert!(rename_apply.exists_after);
1284        assert_eq!(rename_apply.target_path.as_deref(), Some("docs/renamed.md"));
1285        assert!(!temp_dir.join("docs").join("new.md").exists());
1286        assert_eq!(
1287            std::fs::read_to_string(temp_dir.join("docs").join("renamed.md"))
1288                .expect("renamed file"),
1289            "# New\n"
1290        );
1291
1292        let rename_overwrite_response = app
1293            .send(
1294                TestRequest::post("/api/workspace/file-change-preview")
1295                    .header("cookie", &cookie)
1296                    .header("origin", "http://test.local")
1297                    .json(&json!({
1298                        "action": "rename_path",
1299                        "path": "docs/renamed.md",
1300                        "target_path": "docs/guide.md",
1301                        "content": null,
1302                        "expected_modified_at_ms": rename_apply.modified_at_ms_after
1303                    }))
1304                    .expect("workspace file rename overwrite preview request"),
1305            )
1306            .await
1307            .expect("workspace file rename overwrite preview response");
1308        assert_eq!(rename_overwrite_response.status(), StatusCode::CONFLICT);
1309
1310        let delete_preview_response = app
1311            .send(
1312                TestRequest::post("/api/workspace/file-change-preview")
1313                    .header("cookie", &cookie)
1314                    .header("origin", "http://test.local")
1315                    .json(&json!({
1316                        "action": "delete_file",
1317                        "path": "docs/renamed.md",
1318                        "content": null,
1319                        "expected_modified_at_ms": rename_apply.modified_at_ms_after
1320                    }))
1321                    .expect("workspace file delete preview request"),
1322            )
1323            .await
1324            .expect("workspace file delete preview response");
1325        assert_eq!(delete_preview_response.status(), StatusCode::OK);
1326        let delete_preview: WorkspaceFileChangePreviewResponse = delete_preview_response
1327            .json()
1328            .expect("workspace file delete preview payload");
1329        assert!(delete_preview.exists_before);
1330        assert!(delete_preview.diff_preview.contains("-# New"));
1331
1332        let delete_apply_response = app
1333            .send(
1334                TestRequest::post("/api/workspace/file-change-apply")
1335                    .header("cookie", &cookie)
1336                    .header("origin", "http://test.local")
1337                    .json(&json!({ "preview_token": delete_preview.preview_token }))
1338                    .expect("workspace file delete apply request"),
1339            )
1340            .await
1341            .expect("workspace file delete apply response");
1342        assert_eq!(delete_apply_response.status(), StatusCode::OK);
1343        let delete_apply: WorkspaceFileChangeApplyResponse = delete_apply_response
1344            .json()
1345            .expect("workspace file delete apply payload");
1346        assert!(delete_apply.applied);
1347        assert!(!delete_apply.exists_after);
1348        assert!(!temp_dir.join("docs").join("renamed.md").exists());
1349
1350        let file_change_audits_response = app
1351            .send(TestRequest::get("/api/workspace/file-change-audits").header("cookie", &cookie))
1352            .await
1353            .expect("workspace file change audits response");
1354        assert_eq!(file_change_audits_response.status(), StatusCode::OK);
1355        let file_change_audits: Vec<WorkspaceFileChangeAuditEntry> = file_change_audits_response
1356            .json()
1357            .expect("workspace file change audits payload");
1358        let audit_json = serde_json::to_string(&file_change_audits).expect("audit json");
1359        assert_eq!(file_change_audits.len(), 4);
1360        assert_eq!(
1361            file_change_audits
1362                .iter()
1363                .filter(|audit| audit.status == WorkspaceFileChangeAuditStatus::Complete)
1364                .count(),
1365            3
1366        );
1367        assert_eq!(
1368            file_change_audits
1369                .iter()
1370                .filter(|audit| audit.status == WorkspaceFileChangeAuditStatus::Failed)
1371                .count(),
1372            1
1373        );
1374        assert!(file_change_audits.iter().any(|audit| {
1375            audit.action == WorkspaceFileChangeAction::RenamePath
1376                && audit.status == WorkspaceFileChangeAuditStatus::Failed
1377                && audit.error_code.as_deref() == Some("workspace_conflict")
1378                && audit
1379                    .error
1380                    .as_deref()
1381                    .is_some_and(|error| error.contains("already exists"))
1382        }));
1383        let conflict_file_change_audits_response = app
1384            .send(
1385                TestRequest::get("/api/workspace/file-change-audits?error_code=workspace_conflict")
1386                    .header("cookie", &cookie),
1387            )
1388            .await
1389            .expect("workspace conflict file change audits response");
1390        assert_eq!(
1391            conflict_file_change_audits_response.status(),
1392            StatusCode::OK
1393        );
1394        let conflict_file_change_audits: Vec<WorkspaceFileChangeAuditEntry> =
1395            conflict_file_change_audits_response
1396                .json()
1397                .expect("workspace conflict file change audits payload");
1398        assert_eq!(conflict_file_change_audits.len(), 1);
1399        assert_eq!(
1400            conflict_file_change_audits[0].error_code.as_deref(),
1401            Some("workspace_conflict")
1402        );
1403        assert!(!audit_json.contains("# New"));
1404        assert!(!audit_json.contains("diff_preview"));
1405
1406        let unsafe_change_preview_response = app
1407            .send(
1408                TestRequest::post("/api/workspace/file-change-preview")
1409                    .header("cookie", &cookie)
1410                    .header("origin", "http://test.local")
1411                    .json(&json!({
1412                        "action": "write_text",
1413                        "path": "..",
1414                        "content": "outside",
1415                        "expected_modified_at_ms": null
1416                    }))
1417                    .expect("unsafe workspace file change preview request"),
1418            )
1419            .await
1420            .expect("unsafe workspace file change preview response");
1421        assert_eq!(
1422            unsafe_change_preview_response.status(),
1423            StatusCode::BAD_REQUEST
1424        );
1425        let unsafe_change_error: ApiErrorResponse = unsafe_change_preview_response
1426            .json()
1427            .expect("unsafe workspace file change preview error");
1428        assert_eq!(unsafe_change_error.code, "invalid_request");
1429        assert_eq!(unsafe_change_error.status, StatusCode::BAD_REQUEST.as_u16());
1430
1431        let directory_preview_response = app
1432            .send(
1433                TestRequest::get("/api/workspace/file-preview?path=docs").header("cookie", &cookie),
1434            )
1435            .await
1436            .expect("workspace directory preview response");
1437        assert_eq!(directory_preview_response.status(), StatusCode::BAD_REQUEST);
1438
1439        let source_root_response = app
1440            .send(
1441                TestRequest::post("/api/workspace/source-root")
1442                    .header("cookie", &cookie)
1443                    .header("origin", "http://test.local")
1444                    .json(&json!({ "path": "docs" }))
1445                    .expect("workspace source root request"),
1446            )
1447            .await
1448            .expect("workspace source root response");
1449        assert_eq!(source_root_response.status(), StatusCode::OK);
1450        let source_root: SourceRootSummary = source_root_response
1451            .json()
1452            .expect("workspace source root payload");
1453        assert!(source_root.read_only);
1454        assert!(source_root.path.replace('\\', "/").ends_with("/docs"));
1455
1456        let source_root_rescan_response = app
1457            .send(
1458                TestRequest::post("/api/ingest/rescan")
1459                    .header("cookie", &cookie)
1460                    .header("origin", "http://test.local")
1461                    .json(&json!({ "source_root_id": source_root.id }))
1462                    .expect("workspace source root rescan request"),
1463            )
1464            .await
1465            .expect("workspace source root rescan response");
1466        assert_eq!(source_root_rescan_response.status(), StatusCode::OK);
1467        let source_root_rescan: IngestJobSummary = source_root_rescan_response
1468            .json()
1469            .expect("workspace source root rescan payload");
1470        assert_eq!(source_root_rescan.status, "complete");
1471        assert_eq!(source_root_rescan.file_count, 1);
1472
1473        let workspace_task_response = app
1474            .send(
1475                TestRequest::post("/api/workspace/task")
1476                    .header("cookie", &cookie)
1477                    .header("origin", "http://test.local")
1478                    .json(&json!({ "task_id": "git_status" }))
1479                    .expect("workspace task request"),
1480            )
1481            .await
1482            .expect("workspace task response");
1483        assert_eq!(workspace_task_response.status(), StatusCode::OK);
1484        let workspace_task: WorkspaceTaskRunResponse = workspace_task_response
1485            .json()
1486            .expect("workspace task payload");
1487        assert_eq!(workspace_task.path, ".");
1488        assert_eq!(workspace_task.command_label, "git status --short --branch");
1489        assert!(!workspace_task.timed_out);
1490        assert!(!workspace_task.cancelled);
1491        assert_eq!(workspace_task.exit_code, Some(0));
1492        assert!(workspace_task.stdout.contains("?? README.md"));
1493        assert_eq!(workspace_task.max_output_bytes, 64 * 1024);
1494
1495        let inline_long_task_response = app
1496            .send(
1497                TestRequest::post("/api/workspace/task")
1498                    .header("cookie", &cookie)
1499                    .header("origin", "http://test.local")
1500                    .json(&json!({ "task_id": "cargo_check" }))
1501                    .expect("inline long workspace task request"),
1502            )
1503            .await
1504            .expect("inline long workspace task response");
1505        assert_eq!(inline_long_task_response.status(), StatusCode::BAD_REQUEST);
1506        let inline_long_task_error: ApiErrorResponse = inline_long_task_response
1507            .json()
1508            .expect("inline long workspace task error");
1509        assert_eq!(inline_long_task_error.code, "invalid_request");
1510        assert_eq!(
1511            inline_long_task_error.status,
1512            StatusCode::BAD_REQUEST.as_u16()
1513        );
1514
1515        let task_run_start_response = app
1516            .send(
1517                TestRequest::post("/api/workspace/task-runs")
1518                    .header("cookie", &cookie)
1519                    .header("origin", "http://test.local")
1520                    .json(&json!({ "task_id": "git_status" }))
1521                    .expect("workspace task run start request"),
1522            )
1523            .await
1524            .expect("workspace task run start response");
1525        assert_eq!(task_run_start_response.status(), StatusCode::OK);
1526        let task_run: WorkspaceTaskRunSummary = task_run_start_response
1527            .json()
1528            .expect("workspace task run start payload");
1529        assert_eq!(task_run.path, ".");
1530        assert_eq!(task_run.status, WorkspaceTaskRunStatus::Running);
1531        assert_eq!(task_run.command_label, "git status --short --branch");
1532        assert_eq!(task_run.exit_code, None);
1533
1534        let mut completed_task_run = None;
1535        for _ in 0..50 {
1536            let task_run_response = app
1537                .send(
1538                    TestRequest::get(format!("/api/workspace/task-runs/{}", task_run.run_id))
1539                        .header("cookie", &cookie),
1540                )
1541                .await
1542                .expect("workspace task run get response");
1543            assert_eq!(task_run_response.status(), StatusCode::OK);
1544            let latest: WorkspaceTaskRunSummary = task_run_response
1545                .json()
1546                .expect("workspace task run get payload");
1547            if matches!(
1548                latest.status,
1549                WorkspaceTaskRunStatus::Complete
1550                    | WorkspaceTaskRunStatus::Failed
1551                    | WorkspaceTaskRunStatus::Cancelled
1552                    | WorkspaceTaskRunStatus::TimedOut
1553            ) {
1554                completed_task_run = Some(latest);
1555                break;
1556            }
1557            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1558        }
1559        let completed_task_run = completed_task_run.expect("workspace task run should finish");
1560        assert_eq!(completed_task_run.status, WorkspaceTaskRunStatus::Complete);
1561        assert_eq!(completed_task_run.exit_code, Some(0));
1562        assert!(completed_task_run.stdout_tail.contains("?? README.md"));
1563        assert!(completed_task_run.completed_at_ms.is_some());
1564        assert_eq!(completed_task_run.max_output_bytes, 64 * 1024);
1565
1566        let task_runs_response = app
1567            .send(TestRequest::get("/api/workspace/task-runs").header("cookie", &cookie))
1568            .await
1569            .expect("workspace task runs list response");
1570        assert_eq!(task_runs_response.status(), StatusCode::OK);
1571        let task_runs: Vec<WorkspaceTaskRunSummary> = task_runs_response
1572            .json()
1573            .expect("workspace task runs list payload");
1574        assert!(task_runs.iter().any(|run| run.run_id == task_run.run_id));
1575
1576        context
1577            .workspace_task_runs
1578            .write()
1579            .expect("workspace task runs")
1580            .clear();
1581        let persisted_task_run_response = app
1582            .send(
1583                TestRequest::get(format!("/api/workspace/task-runs/{}", task_run.run_id))
1584                    .header("cookie", &cookie),
1585            )
1586            .await
1587            .expect("persisted workspace task run get response");
1588        assert_eq!(persisted_task_run_response.status(), StatusCode::OK);
1589        let persisted_task_run: WorkspaceTaskRunSummary = persisted_task_run_response
1590            .json()
1591            .expect("persisted workspace task run payload");
1592        assert_eq!(persisted_task_run.run_id, task_run.run_id);
1593        assert_eq!(persisted_task_run.status, WorkspaceTaskRunStatus::Complete);
1594
1595        let persisted_task_runs_response = app
1596            .send(TestRequest::get("/api/workspace/task-runs").header("cookie", &cookie))
1597            .await
1598            .expect("persisted workspace task runs list response");
1599        assert_eq!(persisted_task_runs_response.status(), StatusCode::OK);
1600        let persisted_task_runs: Vec<WorkspaceTaskRunSummary> = persisted_task_runs_response
1601            .json()
1602            .expect("persisted workspace task runs list payload");
1603        assert!(
1604            persisted_task_runs
1605                .iter()
1606                .any(|run| run.run_id == task_run.run_id)
1607        );
1608        let filtered_task_runs_response = app
1609            .send(
1610                TestRequest::get("/api/workspace/task-runs?error_code=workspace_task_failed_exit")
1611                    .header("cookie", &cookie),
1612            )
1613            .await
1614            .expect("filtered workspace task runs list response");
1615        assert_eq!(filtered_task_runs_response.status(), StatusCode::OK);
1616        let filtered_task_runs: Vec<WorkspaceTaskRunSummary> = filtered_task_runs_response
1617            .json()
1618            .expect("filtered workspace task runs list payload");
1619        assert!(filtered_task_runs.is_empty());
1620
1621        let cancel_completed_response = app
1622            .send(
1623                TestRequest::post(format!(
1624                    "/api/workspace/task-runs/{}/cancel",
1625                    task_run.run_id
1626                ))
1627                .header("cookie", &cookie)
1628                .header("origin", "http://test.local"),
1629            )
1630            .await
1631            .expect("workspace task run cancel response");
1632        assert_eq!(cancel_completed_response.status(), StatusCode::OK);
1633        let cancel_completed: WorkspaceTaskRunSummary = cancel_completed_response
1634            .json()
1635            .expect("workspace task run cancel payload");
1636        assert_eq!(cancel_completed.status, WorkspaceTaskRunStatus::Complete);
1637
1638        let cargo_check_start_response = app
1639            .send(
1640                TestRequest::post("/api/workspace/task-runs")
1641                    .header("cookie", &cookie)
1642                    .header("origin", "http://test.local")
1643                    .json(&json!({ "task_id": "cargo_check" }))
1644                    .expect("workspace cargo check start request"),
1645            )
1646            .await
1647            .expect("workspace cargo check start response");
1648        assert_eq!(cargo_check_start_response.status(), StatusCode::OK);
1649        let cargo_check: WorkspaceTaskRunSummary = cargo_check_start_response
1650            .json()
1651            .expect("workspace cargo check start payload");
1652        assert_eq!(cargo_check.path, ".");
1653        assert_eq!(cargo_check.status, WorkspaceTaskRunStatus::Running);
1654        assert_eq!(cargo_check.command_label, "cargo check --workspace");
1655
1656        let mut completed_cargo_check = None;
1657        for _ in 0..100 {
1658            let cargo_check_response = app
1659                .send(
1660                    TestRequest::get(format!("/api/workspace/task-runs/{}", cargo_check.run_id))
1661                        .header("cookie", &cookie),
1662                )
1663                .await
1664                .expect("workspace cargo check get response");
1665            assert_eq!(cargo_check_response.status(), StatusCode::OK);
1666            let latest: WorkspaceTaskRunSummary = cargo_check_response
1667                .json()
1668                .expect("workspace cargo check get payload");
1669            if matches!(
1670                latest.status,
1671                WorkspaceTaskRunStatus::Complete
1672                    | WorkspaceTaskRunStatus::Failed
1673                    | WorkspaceTaskRunStatus::Cancelled
1674                    | WorkspaceTaskRunStatus::TimedOut
1675            ) {
1676                completed_cargo_check = Some(latest);
1677                break;
1678            }
1679            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1680        }
1681        let completed_cargo_check =
1682            completed_cargo_check.expect("workspace cargo check should finish");
1683        assert_eq!(
1684            completed_cargo_check.status,
1685            WorkspaceTaskRunStatus::Complete
1686        );
1687        assert_eq!(completed_cargo_check.exit_code, Some(0));
1688        assert_eq!(completed_cargo_check.path, ".");
1689
1690        let scoped_cargo_check_response = app
1691            .send(
1692                TestRequest::post("/api/workspace/task-runs")
1693                    .header("cookie", &cookie)
1694                    .header("origin", "http://test.local")
1695                    .json(&json!({ "task_id": "cargo_check", "path": "docs" }))
1696                    .expect("workspace scoped cargo check request"),
1697            )
1698            .await
1699            .expect("workspace scoped cargo check response");
1700        assert_eq!(
1701            scoped_cargo_check_response.status(),
1702            StatusCode::BAD_REQUEST
1703        );
1704        let scoped_cargo_check_error: ApiErrorResponse = scoped_cargo_check_response
1705            .json()
1706            .expect("workspace scoped cargo check error");
1707        assert_eq!(scoped_cargo_check_error.code, "invalid_request");
1708
1709        let git_add = std::process::Command::new("git")
1710            .arg("add")
1711            .arg("README.md")
1712            .current_dir(&temp_dir)
1713            .output()
1714            .expect("run git add");
1715        assert!(
1716            git_add.status.success(),
1717            "git add failed: {}",
1718            String::from_utf8_lossy(&git_add.stderr)
1719        );
1720        std::fs::write(temp_dir.join("README.md"), "# Readme\n\nchanged").expect("edit readme");
1721
1722        let workspace_diff_response = app
1723            .send(
1724                TestRequest::post("/api/workspace/task")
1725                    .header("cookie", &cookie)
1726                    .header("origin", "http://test.local")
1727                    .json(&json!({ "task_id": "git_diff", "path": "README.md" }))
1728                    .expect("workspace diff request"),
1729            )
1730            .await
1731            .expect("workspace diff response");
1732        assert_eq!(workspace_diff_response.status(), StatusCode::OK);
1733        let workspace_diff: WorkspaceTaskRunResponse = workspace_diff_response
1734            .json()
1735            .expect("workspace diff payload");
1736        assert_eq!(workspace_diff.path, "README.md");
1737        assert_eq!(workspace_diff.command_label, "git diff --no-ext-diff");
1738        assert_eq!(workspace_diff.exit_code, Some(0));
1739        assert!(!workspace_diff.cancelled);
1740        assert!(workspace_diff.stdout.contains("-# Readme"));
1741        assert!(workspace_diff.stdout.contains("+changed"));
1742
1743        let unsafe_workspace_task_response = app
1744            .send(
1745                TestRequest::post("/api/workspace/task")
1746                    .header("cookie", &cookie)
1747                    .header("origin", "http://test.local")
1748                    .json(&json!({ "task_id": "git_status", "path": ".." }))
1749                    .expect("unsafe workspace task request"),
1750            )
1751            .await
1752            .expect("unsafe workspace task response");
1753        assert_eq!(
1754            unsafe_workspace_task_response.status(),
1755            StatusCode::BAD_REQUEST
1756        );
1757        let unsafe_workspace_task_error: ApiErrorResponse = unsafe_workspace_task_response
1758            .json()
1759            .expect("unsafe workspace task error");
1760        assert_eq!(unsafe_workspace_task_error.code, "invalid_request");
1761
1762        let unsafe_task_run_response = app
1763            .send(
1764                TestRequest::post("/api/workspace/task-runs")
1765                    .header("cookie", &cookie)
1766                    .header("origin", "http://test.local")
1767                    .json(&json!({ "task_id": "git_status", "path": ".." }))
1768                    .expect("unsafe workspace task run request"),
1769            )
1770            .await
1771            .expect("unsafe workspace task run response");
1772        assert_eq!(unsafe_task_run_response.status(), StatusCode::BAD_REQUEST);
1773        let unsafe_task_run_error: ApiErrorResponse = unsafe_task_run_response
1774            .json()
1775            .expect("unsafe workspace task run error");
1776        assert_eq!(unsafe_task_run_error.code, "invalid_request");
1777
1778        let missing_task_run_response = app
1779            .send(
1780                TestRequest::get(format!("/api/workspace/task-runs/{}", Uuid::new_v4()))
1781                    .header("cookie", &cookie),
1782            )
1783            .await
1784            .expect("missing workspace task run response");
1785        assert_eq!(missing_task_run_response.status(), StatusCode::NOT_FOUND);
1786        let missing_task_run_error: ApiErrorResponse = missing_task_run_response
1787            .json()
1788            .expect("missing workspace task run error");
1789        assert_eq!(missing_task_run_error.code, "workspace_task_run_not_found");
1790
1791        let unsafe_source_root_response = app
1792            .send(
1793                TestRequest::post("/api/workspace/source-root")
1794                    .header("cookie", &cookie)
1795                    .header("origin", "http://test.local")
1796                    .json(&json!({ "path": ".." }))
1797                    .expect("unsafe workspace source root request"),
1798            )
1799            .await
1800            .expect("unsafe workspace source root response");
1801        assert_eq!(
1802            unsafe_source_root_response.status(),
1803            StatusCode::BAD_REQUEST
1804        );
1805
1806        let escape_response = app
1807            .send(TestRequest::get("/api/workspace/files?path=..").header("cookie", &cookie))
1808            .await
1809            .expect("workspace escape response");
1810        assert_eq!(escape_response.status(), StatusCode::BAD_REQUEST);
1811
1812        let missing_response = app
1813            .send(TestRequest::get("/api/workspace/files?path=missing").header("cookie", &cookie))
1814            .await
1815            .expect("workspace missing response");
1816        assert_eq!(missing_response.status(), StatusCode::NOT_FOUND);
1817
1818        let _ = std::fs::remove_dir_all(temp_dir);
1819    }
1820
1821    #[tokio::test]
1822    async fn conversation_routes_roundtrip_and_rehydrate_on_restart() {
1823        let temp_dir =
1824            std::env::temp_dir().join(format!("soma-studio-http-test-{}", Uuid::new_v4()));
1825        std::fs::create_dir_all(&temp_dir).expect("temp dir");
1826        let config = AppConfig {
1827            app_name: "Soma Studio".to_string(),
1828            bind_addr: "127.0.0.1:0".to_string(),
1829            project_root: temp_dir.clone(),
1830            data_dir: temp_dir.clone(),
1831            derived_dir: temp_dir.join("derived"),
1832            notebook_dir: temp_dir.join("notebook"),
1833            user_assets_dir: temp_dir.join("assets"),
1834            db_path: temp_dir.join("test.db"),
1835            web_build_dir: temp_dir.join("web"),
1836            web_shell_file: temp_dir.join("web").join("spa.html"),
1837        };
1838        let storage = StudioStorage::open(&config).await.expect("storage");
1839        let context = AppContext::new(config.clone(), storage.clone());
1840        let session = context.issue_session().await.expect("session");
1841        let cookie = format!("soma_studio_session={}", session.id);
1842        let app = TestApp::new(build_ingress(&context), ());
1843
1844        let unauthorized_response = app
1845            .send(
1846                TestRequest::get("/api/conversations")
1847                    .header("cookie", format!("soma_studio_session={}", Uuid::new_v4())),
1848            )
1849            .await
1850            .expect("unauthorized response");
1851        assert_eq!(unauthorized_response.status(), StatusCode::UNAUTHORIZED);
1852
1853        let create_response = app
1854            .send(
1855                TestRequest::post("/api/conversations")
1856                    .header("cookie", &cookie)
1857                    .header("origin", "http://test.local")
1858                    .json(&json!({}))
1859                    .expect("conversation create request"),
1860            )
1861            .await
1862            .expect("create response");
1863        assert_eq!(create_response.status(), StatusCode::OK);
1864        let conversation: ConversationSummary =
1865            create_response.json().expect("conversation payload");
1866
1867        let chat_response = app
1868            .send(
1869                TestRequest::post("/api/chat/send")
1870                    .header("cookie", &cookie)
1871                    .header("origin", "http://test.local")
1872                    .json(&json!({
1873                        "conversation_id": conversation.id,
1874                        "message": "restore this conversation after restart"
1875                    }))
1876                    .expect("chat request"),
1877            )
1878            .await
1879            .expect("chat response");
1880        assert_eq!(chat_response.status(), StatusCode::OK);
1881        let chat_payload: ChatSendResponse = chat_response.json().expect("chat payload");
1882        assert_eq!(chat_payload.conversation_id, conversation.id);
1883        assert!(!chat_payload.accepted);
1884        assert!(chat_payload.warning.is_some());
1885        assert_eq!(chat_payload.retrieval_strategy, "none");
1886        assert_eq!(chat_payload.retrieval_result_count, 0);
1887
1888        let list_response = app
1889            .send(TestRequest::get("/api/conversations").header("cookie", &cookie))
1890            .await
1891            .expect("list response");
1892        assert_eq!(list_response.status(), StatusCode::OK);
1893        let listed: Vec<ConversationSummary> = list_response.json().expect("conversation list");
1894        assert_eq!(listed.len(), 1);
1895
1896        let messages_response = app
1897            .send(
1898                TestRequest::get(format!("/api/conversations/{}/messages", conversation.id))
1899                    .header("cookie", &cookie),
1900            )
1901            .await
1902            .expect("messages response");
1903        assert_eq!(
1904            messages_response.status(),
1905            StatusCode::OK,
1906            "{}",
1907            messages_response.text().unwrap_or("<non-utf8>")
1908        );
1909        let messages: Vec<ConversationMessage> =
1910            messages_response.json().expect("messages payload");
1911        assert_eq!(messages.len(), 2);
1912        assert_eq!(messages[0].role, "user");
1913        assert_eq!(messages[1].role, "assistant");
1914
1915        let restarted_context = AppContext::new(config, storage);
1916        let restarted_app = TestApp::new(build_ingress(&restarted_context), ());
1917        let restarted_init_response = restarted_app
1918            .send(TestRequest::get("/api/app/init").header("cookie", &cookie))
1919            .await
1920            .expect("restarted init response");
1921        assert_eq!(restarted_init_response.status(), StatusCode::OK);
1922        let restarted_init: serde_json::Value = restarted_init_response
1923            .json()
1924            .expect("restarted init payload");
1925        assert_eq!(
1926            restarted_init
1927                .get("authenticated")
1928                .and_then(|value| value.as_bool()),
1929            Some(true)
1930        );
1931        assert_eq!(
1932            restarted_init
1933                .get("session_id")
1934                .and_then(|value| value.as_str()),
1935            Some(session.id.as_str())
1936        );
1937
1938        let restarted_list_response = restarted_app
1939            .send(TestRequest::get("/api/conversations").header("cookie", &cookie))
1940            .await
1941            .expect("restarted list response");
1942        assert_eq!(restarted_list_response.status(), StatusCode::OK);
1943        let restarted_list: Vec<ConversationSummary> = restarted_list_response
1944            .json()
1945            .expect("restarted conversation list");
1946        assert_eq!(restarted_list.len(), 1);
1947        assert_eq!(restarted_list[0].id, conversation.id);
1948
1949        let delete_response = restarted_app
1950            .send(
1951                TestRequest::delete(format!("/api/conversations/{}", conversation.id))
1952                    .header("cookie", &cookie)
1953                    .header("origin", "http://test.local"),
1954            )
1955            .await
1956            .expect("delete response");
1957        assert_eq!(delete_response.status(), StatusCode::OK);
1958        let delete_payload: ConversationDeleteResponse =
1959            delete_response.json().expect("delete payload");
1960        assert!(delete_payload.deleted);
1961    }
1962
1963    #[tokio::test]
1964    async fn provider_mutation_routes_reject_unsupported_providers_as_client_errors() {
1965        let temp_dir =
1966            std::env::temp_dir().join(format!("soma-studio-provider-http-{}", Uuid::new_v4()));
1967        std::fs::create_dir_all(&temp_dir).expect("temp dir");
1968        let config = AppConfig {
1969            app_name: "Soma Studio".to_string(),
1970            bind_addr: "127.0.0.1:0".to_string(),
1971            project_root: temp_dir.clone(),
1972            data_dir: temp_dir.clone(),
1973            derived_dir: temp_dir.join("derived"),
1974            notebook_dir: temp_dir.join("notebook"),
1975            user_assets_dir: temp_dir.join("assets"),
1976            db_path: temp_dir.join("test.db"),
1977            web_build_dir: temp_dir.join("web"),
1978            web_shell_file: temp_dir.join("web").join("spa.html"),
1979        };
1980        let storage = StudioStorage::open(&config).await.expect("storage");
1981        let context = AppContext::new(config, storage);
1982        let session = context.issue_session().await.expect("session");
1983        let cookie = format!("soma_studio_session={}", session.id);
1984        let app = TestApp::new(build_ingress(&context), ());
1985
1986        let selection_response = app
1987            .send(
1988                TestRequest::post("/api/providers/selection")
1989                    .header("cookie", &cookie)
1990                    .header("origin", "http://test.local")
1991                    .json(&json!({
1992                        "provider": "custom",
1993                        "model_id": "model-a"
1994                    }))
1995                    .expect("provider selection request"),
1996            )
1997            .await
1998            .expect("provider selection response");
1999        assert_eq!(selection_response.status(), StatusCode::BAD_REQUEST);
2000
2001        let test_response = app
2002            .send(
2003                TestRequest::post("/api/providers/test")
2004                    .header("cookie", &cookie)
2005                    .header("origin", "http://test.local")
2006                    .json(&json!({
2007                        "provider": "custom"
2008                    }))
2009                    .expect("provider test request"),
2010            )
2011            .await
2012            .expect("provider test response");
2013        assert_eq!(test_response.status(), StatusCode::BAD_REQUEST);
2014    }
2015
2016    #[tokio::test]
2017    async fn provider_mutation_routes_normalize_provider_identifiers() {
2018        let temp_dir =
2019            std::env::temp_dir().join(format!("soma-studio-provider-normalize-{}", Uuid::new_v4()));
2020        std::fs::create_dir_all(&temp_dir).expect("temp dir");
2021        let config = AppConfig {
2022            app_name: "Soma Studio".to_string(),
2023            bind_addr: "127.0.0.1:0".to_string(),
2024            project_root: temp_dir.clone(),
2025            data_dir: temp_dir.clone(),
2026            derived_dir: temp_dir.join("derived"),
2027            notebook_dir: temp_dir.join("notebook"),
2028            user_assets_dir: temp_dir.join("assets"),
2029            db_path: temp_dir.join("test.db"),
2030            web_build_dir: temp_dir.join("web"),
2031            web_shell_file: temp_dir.join("web").join("spa.html"),
2032        };
2033        let storage = StudioStorage::open(&config).await.expect("storage");
2034        let context = AppContext::new(config, storage);
2035        let session = context.issue_session().await.expect("session");
2036        let cookie = format!("soma_studio_session={}", session.id);
2037        let app = TestApp::new(build_ingress(&context), ());
2038
2039        let selection_response = app
2040            .send(
2041                TestRequest::post("/api/providers/selection")
2042                    .header("cookie", &cookie)
2043                    .header("origin", "http://test.local")
2044                    .json(&json!({
2045                        "provider": " Ollama ",
2046                        "model_id": " model-a "
2047                    }))
2048                    .expect("provider selection request"),
2049            )
2050            .await
2051            .expect("provider selection response");
2052        assert_eq!(selection_response.status(), StatusCode::OK);
2053        let selection: ProviderSelectionResponse = selection_response
2054            .json()
2055            .expect("provider selection payload");
2056        assert_eq!(selection.selected_provider.as_deref(), Some("ollama"));
2057        assert_eq!(selection.selected_model_id.as_deref(), Some("model-a"));
2058
2059        let test_response = app
2060            .send(
2061                TestRequest::post("/api/providers/test")
2062                    .header("cookie", &cookie)
2063                    .header("origin", "http://test.local")
2064                    .json(&json!({
2065                        "provider": " LMSTUDIO "
2066                    }))
2067                    .expect("provider test request"),
2068            )
2069            .await
2070            .expect("provider test response");
2071        assert_eq!(test_response.status(), StatusCode::OK);
2072        let provider_test: ProviderTestResponse =
2073            test_response.json().expect("provider test payload");
2074        assert_eq!(provider_test.provider, "lmstudio");
2075        assert_eq!(provider_test.endpoint, "http://127.0.0.1:1234");
2076    }
2077
2078    #[tokio::test]
2079    async fn notebook_routes_roundtrip_markdown_and_typst_notes() {
2080        let temp_dir =
2081            std::env::temp_dir().join(format!("soma-studio-notebook-http-{}", Uuid::new_v4()));
2082        std::fs::create_dir_all(&temp_dir).expect("temp dir");
2083        let config = AppConfig {
2084            app_name: "Soma Studio".to_string(),
2085            bind_addr: "127.0.0.1:0".to_string(),
2086            project_root: temp_dir.clone(),
2087            data_dir: temp_dir.clone(),
2088            derived_dir: temp_dir.join("derived"),
2089            notebook_dir: temp_dir.join("notebook"),
2090            user_assets_dir: temp_dir.join("assets"),
2091            db_path: temp_dir.join("test.db"),
2092            web_build_dir: temp_dir.join("web"),
2093            web_shell_file: temp_dir.join("web").join("spa.html"),
2094        };
2095        let storage = StudioStorage::open(&config).await.expect("storage");
2096        let context = AppContext::new(config, storage);
2097        let session = context.issue_session().await.expect("session");
2098        let cookie = format!("soma_studio_session={}", session.id);
2099        let app = TestApp::new(build_ingress(&context), ());
2100
2101        let create_typst_response = app
2102            .send(
2103                TestRequest::post("/api/notebook/note")
2104                    .header("cookie", &cookie)
2105                    .header("origin", "http://test.local")
2106                    .json(&json!({
2107                        "path": "reports/weekly.typ",
2108                        "format": "typst"
2109                    }))
2110                    .expect("typst create request"),
2111            )
2112            .await
2113            .expect("typst create response");
2114        assert_eq!(create_typst_response.status(), StatusCode::OK);
2115        let typst_note: NotebookNoteContent = create_typst_response.json().expect("typst note");
2116        assert_eq!(typst_note.format, NotebookNoteFormat::Typst);
2117        assert_eq!(typst_note.render_status, "not_rendered");
2118
2119        let write_response = app
2120            .send(
2121                TestRequest::put("/api/notebook/note")
2122                    .header("cookie", &cookie)
2123                    .header("origin", "http://test.local")
2124                    .json(&json!({
2125                        "path": "reports/weekly.typ",
2126                        "content": "= Weekly Report\n\nTypst artifact planning"
2127                    }))
2128                    .expect("typst write request"),
2129            )
2130            .await
2131            .expect("typst write response");
2132        assert_eq!(write_response.status(), StatusCode::OK);
2133
2134        let list_response = app
2135            .send(TestRequest::get("/api/notebook/tree").header("cookie", &cookie))
2136            .await
2137            .expect("tree response");
2138        assert_eq!(list_response.status(), StatusCode::OK);
2139        let listed: Vec<NotebookNoteSummary> = list_response.json().expect("tree payload");
2140        assert_eq!(listed.len(), 1);
2141        assert_eq!(listed[0].path, "reports/weekly.typ");
2142
2143        let read_response = app
2144            .send(
2145                TestRequest::get("/api/notebook/note?path=reports%2Fweekly.typ")
2146                    .header("cookie", &cookie),
2147            )
2148            .await
2149            .expect("encoded note read response");
2150        assert_eq!(read_response.status(), StatusCode::OK);
2151        let read_note: NotebookNoteContent = read_response.json().expect("encoded note payload");
2152        assert_eq!(read_note.path, "reports/weekly.typ");
2153        assert_eq!(
2154            read_note.content,
2155            "= Weekly Report\n\nTypst artifact planning"
2156        );
2157
2158        let search_response = app
2159            .send(TestRequest::get("/api/notebook/search?query=artifact").header("cookie", &cookie))
2160            .await
2161            .expect("search response");
2162        assert_eq!(search_response.status(), StatusCode::OK);
2163        let results: Vec<NotebookSearchResult> = search_response.json().expect("search payload");
2164        assert_eq!(results.len(), 1);
2165        assert_eq!(results[0].format, NotebookNoteFormat::Typst);
2166
2167        let index_response = app
2168            .send(
2169                TestRequest::post("/api/notebook/index")
2170                    .header("cookie", &cookie)
2171                    .header("origin", "http://test.local"),
2172            )
2173            .await
2174            .expect("index response");
2175        assert_eq!(index_response.status(), StatusCode::OK);
2176        let index: NotebookIndexResponse = index_response.json().expect("index payload");
2177        assert_eq!(index.indexed, 1);
2178        assert_eq!(index.items[0].path, "reports/weekly.typ");
2179        assert_eq!(
2180            index.items[0].index_path,
2181            "notebook-index/reports/weekly.txt"
2182        );
2183
2184        let index_status_response = app
2185            .send(TestRequest::get("/api/notebook/index").header("cookie", &cookie))
2186            .await
2187            .expect("index status response");
2188        assert_eq!(index_status_response.status(), StatusCode::OK);
2189        let index_status: NotebookIndexResponse =
2190            index_status_response.json().expect("index status payload");
2191        assert_eq!(index_status.indexed, 1);
2192
2193        let chunks_response = app
2194            .send(
2195                TestRequest::post("/api/notebook/chunks")
2196                    .header("cookie", &cookie)
2197                    .header("origin", "http://test.local"),
2198            )
2199            .await
2200            .expect("chunks response");
2201        assert_eq!(chunks_response.status(), StatusCode::OK);
2202        let chunks: NotebookChunkResponse = chunks_response.json().expect("chunks payload");
2203        assert_eq!(chunks.chunked, 1);
2204        assert_eq!(
2205            chunks.items[0].chunk_path,
2206            "notebook-chunks/reports/weekly.json"
2207        );
2208
2209        let chunks_status_response = app
2210            .send(TestRequest::get("/api/notebook/chunks").header("cookie", &cookie))
2211            .await
2212            .expect("chunks status response");
2213        assert_eq!(chunks_status_response.status(), StatusCode::OK);
2214        let chunks_status: NotebookChunkResponse = chunks_status_response
2215            .json()
2216            .expect("chunks status payload");
2217        assert_eq!(chunks_status.chunked, 1);
2218
2219        let embeddings_response = app
2220            .send(
2221                TestRequest::post("/api/notebook/embeddings")
2222                    .header("cookie", &cookie)
2223                    .header("origin", "http://test.local"),
2224            )
2225            .await
2226            .expect("embeddings response");
2227        assert_eq!(embeddings_response.status(), StatusCode::BAD_REQUEST);
2228
2229        let embeddings_status_response = app
2230            .send(TestRequest::get("/api/notebook/embeddings").header("cookie", &cookie))
2231            .await
2232            .expect("embeddings status response");
2233        assert_eq!(embeddings_status_response.status(), StatusCode::BAD_REQUEST);
2234
2235        let retrieval_response = app
2236            .send(
2237                TestRequest::get("/api/notebook/retrieve?query=artifact").header("cookie", &cookie),
2238            )
2239            .await
2240            .expect("retrieval response");
2241        assert_eq!(retrieval_response.status(), StatusCode::OK);
2242        let retrieval: NotebookRetrievalResponse =
2243            retrieval_response.json().expect("retrieval payload");
2244        assert_eq!(retrieval.query, "artifact");
2245        assert_eq!(retrieval.results.len(), 1);
2246        assert_eq!(retrieval.results[0].path, "reports/weekly.typ");
2247        assert!(retrieval.results[0].score > 0);
2248
2249        let retrieval_chat_response = app
2250            .send(
2251                TestRequest::post("/api/chat/send")
2252                    .header("cookie", &cookie)
2253                    .header("origin", "http://test.local")
2254                    .json(&json!({
2255                        "message": "artifact planning"
2256                    }))
2257                    .expect("retrieval chat request"),
2258            )
2259            .await
2260            .expect("retrieval chat response");
2261        assert_eq!(retrieval_chat_response.status(), StatusCode::OK);
2262        let retrieval_chat: ChatSendResponse = retrieval_chat_response
2263            .json()
2264            .expect("retrieval chat payload");
2265        assert_eq!(retrieval_chat.retrieval_strategy, "lexical");
2266        assert_eq!(retrieval_chat.retrieval_result_count, 1);
2267        assert_eq!(retrieval_chat.retrieval_sources.len(), 1);
2268        assert_eq!(
2269            retrieval_chat.retrieval_sources[0].path,
2270            "reports/weekly.typ"
2271        );
2272
2273        let adapters_response = app
2274            .send(TestRequest::get("/api/notebook/adapters").header("cookie", &cookie))
2275            .await
2276            .expect("adapters response");
2277        assert_eq!(adapters_response.status(), StatusCode::OK);
2278        let adapters: Vec<NotebookAdapterStatus> =
2279            adapters_response.json().expect("adapters payload");
2280        let typst_adapter = adapters
2281            .iter()
2282            .find(|adapter| adapter.id == "typst")
2283            .expect("typst adapter");
2284
2285        let render_response = app
2286            .send(
2287                TestRequest::post("/api/notebook/render")
2288                    .header("cookie", &cookie)
2289                    .header("origin", "http://test.local")
2290                    .json(&json!({
2291                        "path": "reports/weekly.typ",
2292                        "target": "pdf"
2293                    }))
2294                    .expect("render request"),
2295            )
2296            .await
2297            .expect("render response");
2298        if typst_adapter.status == "available" {
2299            assert_eq!(render_response.status(), StatusCode::OK);
2300            let render: NotebookRenderResponse = render_response.json().expect("render payload");
2301            assert_eq!(render.status, "complete");
2302            assert_eq!(
2303                render.artifact_url.as_deref(),
2304                Some("/api/notebook/artifact?path=reports/weekly.pdf")
2305            );
2306        } else {
2307            assert_eq!(render_response.status(), StatusCode::OK);
2308            let render: NotebookRenderResponse = render_response.json().expect("render payload");
2309            assert_eq!(render.status, typst_adapter.status);
2310        }
2311
2312        let artifact = context
2313            .config
2314            .derived_dir
2315            .join("notebook-artifacts")
2316            .join("reports")
2317            .join("weekly.pdf");
2318        std::fs::create_dir_all(artifact.parent().expect("artifact parent")).expect("artifact dir");
2319        std::fs::write(&artifact, b"%PDF-1.7").expect("artifact");
2320        let artifact_response = app
2321            .send(
2322                TestRequest::get("/api/notebook/artifact?path=reports%2Fweekly.pdf")
2323                    .header("cookie", &cookie),
2324            )
2325            .await
2326            .expect("artifact response");
2327        assert_eq!(artifact_response.status(), StatusCode::OK);
2328        assert_eq!(artifact_response.body(), b"%PDF-1.7");
2329
2330        let unauthenticated_artifact_response = app
2331            .send(TestRequest::get(
2332                "/api/notebook/artifact?path=reports/weekly.pdf",
2333            ))
2334            .await
2335            .expect("unauthenticated artifact response");
2336        assert_eq!(
2337            unauthenticated_artifact_response.status(),
2338            StatusCode::UNAUTHORIZED
2339        );
2340
2341        let missing_artifact_response = app
2342            .send(
2343                TestRequest::get("/api/notebook/artifact?path=reports/missing.pdf")
2344                    .header("cookie", &cookie),
2345            )
2346            .await
2347            .expect("missing artifact response");
2348        assert_eq!(missing_artifact_response.status(), StatusCode::NOT_FOUND);
2349
2350        let escape_response = app
2351            .send(
2352                TestRequest::post("/api/notebook/note")
2353                    .header("cookie", &cookie)
2354                    .header("origin", "http://test.local")
2355                    .json(&json!({
2356                        "path": "../escape.md",
2357                        "format": "markdown"
2358                    }))
2359                    .expect("escape create request"),
2360            )
2361            .await
2362            .expect("escape response");
2363        assert_eq!(escape_response.status(), StatusCode::BAD_REQUEST);
2364
2365        let _ = std::fs::remove_dir_all(temp_dir);
2366    }
2367
2368    #[tokio::test]
2369    async fn ingest_routes_scan_source_roots_and_report_jobs() {
2370        let temp_dir =
2371            std::env::temp_dir().join(format!("soma-studio-ingest-http-{}", Uuid::new_v4()));
2372        let source_root = temp_dir.join("source");
2373        std::fs::create_dir_all(&source_root).expect("source dir");
2374        std::fs::write(source_root.join("alpha.md"), "# Alpha\n\nhello").expect("alpha");
2375        std::fs::write(source_root.join("beta.txt"), "plain text").expect("beta");
2376        let config = AppConfig {
2377            app_name: "Soma Studio".to_string(),
2378            bind_addr: "127.0.0.1:0".to_string(),
2379            project_root: temp_dir.clone(),
2380            data_dir: temp_dir.clone(),
2381            derived_dir: temp_dir.join("derived"),
2382            notebook_dir: temp_dir.join("notebook"),
2383            user_assets_dir: temp_dir.join("assets"),
2384            db_path: temp_dir.join("test.db"),
2385            web_build_dir: temp_dir.join("web"),
2386            web_shell_file: temp_dir.join("web").join("spa.html"),
2387        };
2388        let storage = StudioStorage::open(&config).await.expect("storage");
2389        let context = AppContext::new(config, storage);
2390        let session = context.issue_session().await.expect("session");
2391        let cookie = format!("soma_studio_session={}", session.id);
2392        let app = TestApp::new(build_ingress(&context), ());
2393
2394        let created_root = app
2395            .send(
2396                TestRequest::post("/api/source-roots")
2397                    .header("cookie", &cookie)
2398                    .header("origin", "http://test.local")
2399                    .json(&json!({ "path": source_root.to_string_lossy() }))
2400                    .expect("root create request"),
2401            )
2402            .await
2403            .expect("root create response")
2404            .json::<SourceRootSummary>()
2405            .expect("root payload");
2406
2407        let rescan_response = app
2408            .send(
2409                TestRequest::post("/api/ingest/rescan")
2410                    .header("cookie", &cookie)
2411                    .header("origin", "http://test.local")
2412                    .json(&json!({ "source_root_id": created_root.id }))
2413                    .expect("rescan request"),
2414            )
2415            .await
2416            .expect("rescan response");
2417        assert_eq!(rescan_response.status(), StatusCode::OK);
2418        let job: IngestJobSummary = rescan_response.json().expect("rescan payload");
2419        assert_eq!(job.status, "complete");
2420        assert_eq!(job.file_count, 2);
2421
2422        let jobs_response = app
2423            .send(TestRequest::get("/api/ingest/jobs").header("cookie", &cookie))
2424            .await
2425            .expect("jobs response");
2426        assert_eq!(jobs_response.status(), StatusCode::OK);
2427        let jobs: Vec<IngestJobSummary> = jobs_response.json().expect("jobs payload");
2428        assert_eq!(jobs.len(), 1);
2429
2430        let status_response = app
2431            .send(TestRequest::get("/api/ingest/status").header("cookie", &cookie))
2432            .await
2433            .expect("status response");
2434        assert_eq!(status_response.status(), StatusCode::OK);
2435        let status: IngestStatusResponse = status_response.json().expect("status payload");
2436        assert!(!status.running);
2437        assert_eq!(status.total_source_files, 2);
2438        assert_eq!(status.indexed_text_files, 2);
2439        assert!(
2440            context
2441                .config
2442                .derived_dir
2443                .join("source-root-text")
2444                .join(created_root.id.to_string())
2445                .join("alpha.txt")
2446                .exists()
2447        );
2448        assert!(
2449            context
2450                .config
2451                .derived_dir
2452                .join("source-root-chunks")
2453                .join(created_root.id.to_string())
2454                .join("alpha.json")
2455                .exists()
2456        );
2457
2458        let retrieval_response = app
2459            .send(TestRequest::get("/api/notebook/retrieve?query=hello").header("cookie", &cookie))
2460            .await
2461            .expect("source-root retrieval response");
2462        assert_eq!(retrieval_response.status(), StatusCode::OK);
2463        let retrieval: NotebookRetrievalResponse = retrieval_response
2464            .json()
2465            .expect("source-root retrieval payload");
2466        assert_eq!(retrieval.strategy, "lexical");
2467        assert_eq!(retrieval.results.len(), 1);
2468        assert_eq!(
2469            retrieval.results[0].path,
2470            format!("source-root/{}/alpha.md", created_root.id)
2471        );
2472        assert_eq!(
2473            retrieval.results[0].chunk_path,
2474            format!("source-root-chunks/{}/alpha.json", created_root.id)
2475        );
2476
2477        let _ = std::fs::remove_dir_all(temp_dir);
2478    }
2479
2480    #[tokio::test]
2481    async fn search_route_returns_notebook_and_source_root_results_without_provider() {
2482        let temp_dir =
2483            std::env::temp_dir().join(format!("soma-studio-search-http-{}", Uuid::new_v4()));
2484        let source_root = temp_dir.join("source");
2485        std::fs::create_dir_all(&source_root).expect("source dir");
2486        std::fs::write(source_root.join("alpha.md"), "# Alpha\n\nsource artifact")
2487            .expect("source file");
2488        let config = AppConfig {
2489            app_name: "Soma Studio".to_string(),
2490            bind_addr: "127.0.0.1:0".to_string(),
2491            project_root: temp_dir.clone(),
2492            data_dir: temp_dir.clone(),
2493            derived_dir: temp_dir.join("derived"),
2494            notebook_dir: temp_dir.join("notebook"),
2495            user_assets_dir: temp_dir.join("assets"),
2496            db_path: temp_dir.join("test.db"),
2497            web_build_dir: temp_dir.join("web"),
2498            web_shell_file: temp_dir.join("web").join("spa.html"),
2499        };
2500        let storage = StudioStorage::open(&config).await.expect("storage");
2501        let context = AppContext::new(config, storage);
2502        let session = context.issue_session().await.expect("session");
2503        let cookie = format!("soma_studio_session={}", session.id);
2504        let app = TestApp::new(build_ingress(&context), ());
2505
2506        let note_response = app
2507            .send(
2508                TestRequest::post("/api/notebook/note")
2509                    .header("cookie", &cookie)
2510                    .header("origin", "http://test.local")
2511                    .json(&json!({
2512                        "path": "notes/search.md",
2513                        "format": "markdown"
2514                    }))
2515                    .expect("note create request"),
2516            )
2517            .await
2518            .expect("note create response");
2519        assert_eq!(note_response.status(), StatusCode::OK);
2520        let write_response = app
2521            .send(
2522                TestRequest::put("/api/notebook/note")
2523                    .header("cookie", &cookie)
2524                    .header("origin", "http://test.local")
2525                    .json(&json!({
2526                        "path": "notes/search.md",
2527                        "content": "# Search\n\nnotebook artifact"
2528                    }))
2529                    .expect("note write request"),
2530            )
2531            .await
2532            .expect("note write response");
2533        assert_eq!(write_response.status(), StatusCode::OK);
2534
2535        let created_root = app
2536            .send(
2537                TestRequest::post("/api/source-roots")
2538                    .header("cookie", &cookie)
2539                    .header("origin", "http://test.local")
2540                    .json(&json!({ "path": source_root.to_string_lossy() }))
2541                    .expect("root create request"),
2542            )
2543            .await
2544            .expect("root create response")
2545            .json::<SourceRootSummary>()
2546            .expect("root payload");
2547        let rescan_response = app
2548            .send(
2549                TestRequest::post("/api/ingest/rescan")
2550                    .header("cookie", &cookie)
2551                    .header("origin", "http://test.local")
2552                    .json(&json!({ "source_root_id": created_root.id }))
2553                    .expect("rescan request"),
2554            )
2555            .await
2556            .expect("rescan response");
2557        assert_eq!(rescan_response.status(), StatusCode::OK);
2558
2559        let missing_index_response = app
2560            .send(TestRequest::get("/api/search/index/status").header("cookie", &cookie))
2561            .await
2562            .expect("missing search index status response");
2563        assert_eq!(missing_index_response.status(), StatusCode::OK);
2564        let missing_index: SearchIndexStatusResponse = missing_index_response
2565            .json()
2566            .expect("missing search index status payload");
2567        assert!(!missing_index.ready);
2568        assert!(!missing_index.exists);
2569
2570        let missing_search_response = app
2571            .send(TestRequest::get("/api/search?q=artifact").header("cookie", &cookie))
2572            .await
2573            .expect("missing index search response");
2574        assert_eq!(missing_search_response.status(), StatusCode::BAD_REQUEST);
2575
2576        let rebuild_response = app
2577            .send(
2578                TestRequest::post("/api/search/index/rebuild")
2579                    .header("cookie", &cookie)
2580                    .header("origin", "http://test.local"),
2581            )
2582            .await
2583            .expect("search index rebuild response");
2584        assert_eq!(rebuild_response.status(), StatusCode::OK);
2585        let rebuild: SearchIndexRebuildResponse = rebuild_response
2586            .json()
2587            .expect("search index rebuild payload");
2588        assert_eq!(rebuild.indexed_documents, 2);
2589        assert!(rebuild.indexed_chunks >= 2);
2590        assert!(rebuild.status.ready);
2591
2592        for endpoint in ["/api/search/index/sync", "/api/search/index/recover"] {
2593            let maintenance_response = app
2594                .send(
2595                    TestRequest::post(endpoint)
2596                        .header("cookie", &cookie)
2597                        .header("origin", "http://test.local"),
2598                )
2599                .await
2600                .expect("search index maintenance response");
2601            assert_eq!(maintenance_response.status(), StatusCode::OK);
2602            let maintenance: SearchIndexRebuildResponse = maintenance_response
2603                .json()
2604                .expect("search index maintenance payload");
2605            assert_eq!(maintenance.indexed_documents, 2);
2606            assert!(maintenance.indexed_chunks >= 2);
2607            assert!(maintenance.status.ready);
2608        }
2609
2610        let ready_index_response = app
2611            .send(TestRequest::get("/api/search/index/status").header("cookie", &cookie))
2612            .await
2613            .expect("ready search index status response");
2614        assert_eq!(ready_index_response.status(), StatusCode::OK);
2615        let ready_index: SearchIndexStatusResponse = ready_index_response
2616            .json()
2617            .expect("ready search index status payload");
2618        assert!(ready_index.ready);
2619        assert_eq!(ready_index.document_count, 2);
2620
2621        let search_response = app
2622            .send(TestRequest::get("/api/search?q=artifact").header("cookie", &cookie))
2623            .await
2624            .expect("search response");
2625        assert_eq!(search_response.status(), StatusCode::OK);
2626        let search: SearchResponse = search_response.json().expect("search payload");
2627        assert_eq!(search.query, "artifact");
2628        assert_eq!(search.field, SearchFieldScope::All);
2629        assert_eq!(search.sort, SearchSort::Relevance);
2630        assert_eq!(search.limit, 25);
2631        assert_eq!(search.offset, 0);
2632        assert_eq!(search.total_results, 2);
2633        assert_eq!(search.results.len(), 2);
2634        assert!(
2635            search
2636                .results
2637                .iter()
2638                .any(|result| result.source_type == SearchSourceType::Notebook)
2639        );
2640        assert!(
2641            search
2642                .results
2643                .iter()
2644                .any(|result| result.source_type == SearchSourceType::SourceRoot)
2645        );
2646        assert!(
2647            search
2648                .results
2649                .iter()
2650                .all(|result| result.highlights == vec!["artifact".to_string()])
2651        );
2652        assert!(
2653            search
2654                .results
2655                .iter()
2656                .all(|result| { result.status == soma_studio_core::SearchDocumentStatus::Ready })
2657        );
2658
2659        let paged_response = app
2660            .send(
2661                TestRequest::get("/api/search?q=artifact&limit=1&offset=1")
2662                    .header("cookie", &cookie),
2663            )
2664            .await
2665            .expect("paged search response");
2666        assert_eq!(paged_response.status(), StatusCode::OK);
2667        let paged: SearchResponse = paged_response.json().expect("paged search payload");
2668        assert_eq!(paged.field, SearchFieldScope::All);
2669        assert_eq!(paged.limit, 1);
2670        assert_eq!(paged.offset, 1);
2671        assert_eq!(paged.total_results, 2);
2672        assert_eq!(paged.previous_cursor.as_deref(), Some("offset:0"));
2673        assert!(paged.next_cursor.is_none());
2674        assert_eq!(paged.results.len(), 1);
2675
2676        let cursor_response = app
2677            .send(
2678                TestRequest::get("/api/search?q=artifact&limit=1&cursor=offset:1")
2679                    .header("cookie", &cookie),
2680            )
2681            .await
2682            .expect("cursor search response");
2683        assert_eq!(cursor_response.status(), StatusCode::OK);
2684        let cursor_page: SearchResponse = cursor_response.json().expect("cursor search payload");
2685        assert_eq!(cursor_page.offset, 1);
2686        assert_eq!(cursor_page.previous_cursor.as_deref(), Some("offset:0"));
2687        assert_eq!(cursor_page.results.len(), 1);
2688
2689        let filtered_response = app
2690            .send(
2691                TestRequest::get("/api/search?q=artifact&source_type=source_root")
2692                    .header("cookie", &cookie),
2693            )
2694            .await
2695            .expect("filtered search response");
2696        assert_eq!(filtered_response.status(), StatusCode::OK);
2697        let filtered: SearchResponse = filtered_response.json().expect("filtered payload");
2698        assert_eq!(filtered.results.len(), 1);
2699        assert_eq!(
2700            filtered.results[0].source_type,
2701            SearchSourceType::SourceRoot
2702        );
2703
2704        let title_field_response = app
2705            .send(TestRequest::get("/api/search?q=artifact&field=title").header("cookie", &cookie))
2706            .await
2707            .expect("title field search response");
2708        assert_eq!(title_field_response.status(), StatusCode::OK);
2709        let title_field: SearchResponse = title_field_response.json().expect("title field payload");
2710        assert_eq!(title_field.field, SearchFieldScope::Title);
2711
2712        let sorted_response = app
2713            .send(
2714                TestRequest::get("/api/search?q=artifact&sort=updated_at")
2715                    .header("cookie", &cookie),
2716            )
2717            .await
2718            .expect("sorted search response");
2719        assert_eq!(sorted_response.status(), StatusCode::OK);
2720        let sorted: SearchResponse = sorted_response.json().expect("sorted payload");
2721        assert_eq!(sorted.sort, SearchSort::UpdatedAt);
2722        assert!(sorted.results.iter().all(|result| result.updated_at_ms > 0));
2723
2724        let invalid_field_response = app
2725            .send(
2726                TestRequest::get("/api/search?q=artifact&field=unknown").header("cookie", &cookie),
2727            )
2728            .await
2729            .expect("invalid field search response");
2730        assert_eq!(invalid_field_response.status(), StatusCode::BAD_REQUEST);
2731
2732        let invalid_sort_response = app
2733            .send(TestRequest::get("/api/search?q=artifact&sort=size").header("cookie", &cookie))
2734            .await
2735            .expect("invalid sort search response");
2736        assert_eq!(invalid_sort_response.status(), StatusCode::BAD_REQUEST);
2737
2738        let invalid_cursor_response = app
2739            .send(TestRequest::get("/api/search?q=artifact&cursor=bad").header("cookie", &cookie))
2740            .await
2741            .expect("invalid cursor search response");
2742        assert_eq!(invalid_cursor_response.status(), StatusCode::BAD_REQUEST);
2743
2744        let mixed_cursor_offset_response = app
2745            .send(
2746                TestRequest::get("/api/search?q=artifact&cursor=offset:1&offset=1")
2747                    .header("cookie", &cookie),
2748            )
2749            .await
2750            .expect("mixed cursor offset search response");
2751        assert_eq!(
2752            mixed_cursor_offset_response.status(),
2753            StatusCode::BAD_REQUEST
2754        );
2755
2756        let encoded_open_path = filtered.results[0].path.replace('/', "%2F");
2757        let open_action_response = app
2758            .send(
2759                TestRequest::get(format!(
2760                    "/api/search/open-action?action=copy_path&path={}",
2761                    encoded_open_path
2762                ))
2763                .header("cookie", &cookie),
2764            )
2765            .await
2766            .expect("source open action response");
2767        assert_eq!(open_action_response.status(), StatusCode::OK);
2768        let open_action: SearchOpenActionResponse = open_action_response
2769            .json()
2770            .expect("source open action payload");
2771        assert!(open_action.allowed);
2772        assert_eq!(open_action.action, "copy_path");
2773        assert!(open_action.canonical_path.ends_with("alpha.md"));
2774
2775        let invalid_filter_response = app
2776            .send(
2777                TestRequest::get("/api/search?q=artifact&source_type=external")
2778                    .header("cookie", &cookie),
2779            )
2780            .await
2781            .expect("invalid filtered search response");
2782        assert_eq!(invalid_filter_response.status(), StatusCode::BAD_REQUEST);
2783
2784        let _ = std::fs::remove_dir_all(temp_dir);
2785    }
2786}