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}