mod embedded_assets {
include!(concat!(env!("OUT_DIR"), "/embedded_web_assets.rs"));
}
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto::Builder as AutoBuilder;
use ranvier::http::ingress::RawIngressService;
use ranvier::http::{
CookieJar, HttpIngress, Ranvier, StaticAssetPolicy, StaticAssetSource, StaticShell,
};
use soma_studio_core::{
AppConfig, ProviderSummary, SourceRootSummary, WorkspaceFileChangePreviewRequest,
WorkspaceTaskRunRequest, WorkspaceTaskRunResponse, WorkspaceTaskRunStatus,
WorkspaceTaskRunSummary,
};
use tokio::net::TcpListener;
use tokio::sync::broadcast;
use tracing::{info, warn};
use uuid::Uuid;
use crate::chat_events::ChatEventEnvelope;
use crate::storage::StudioStorage;
use crate::transitions::{
RequestHost, RequestOrigin, RequestPath, bootstrap_redirect_axon, chat_send_axon,
conversation_delete_axon, conversation_messages_axon, conversations_create_axon,
conversations_list_axon, health_axon, ingest_jobs_axon, ingest_rescan_axon, ingest_status_axon,
init_axon, notebook_adapters_list_axon, notebook_artifact_axon, notebook_chunks_build_axon,
notebook_chunks_get_axon, notebook_embeddings_build_axon, notebook_embeddings_get_axon,
notebook_index_build_axon, notebook_index_get_axon, notebook_note_create_axon,
notebook_note_read_axon, notebook_note_render_axon, notebook_note_write_axon,
notebook_notes_list_axon, notebook_notes_search_axon, notebook_retrieve_axon,
provider_models_axon, provider_models_error_response, provider_selection_get_axon,
provider_selection_set_axon, provider_test_axon, providers_axon, search_axon,
search_index_rebuild_axon, search_index_recover_axon, search_index_status_axon,
search_index_sync_axon, search_open_action_axon, source_roots_create_axon,
source_roots_list_axon, stream_chat_axon, workspace_file_change_apply_axon,
workspace_file_change_audits_list_axon, workspace_file_change_preview_axon,
workspace_file_preview_axon, workspace_files_axon, workspace_source_root_create_axon,
workspace_task_run_axon, workspace_task_run_cancel_axon, workspace_task_run_get_axon,
workspace_task_run_start_axon, workspace_task_runs_list_axon,
};
use crate::workspace::WorkspaceError;
use crate::workspace_tasks::WorkspaceTaskError;
#[derive(Debug, Clone)]
pub struct SessionInfo {
pub id: String,
}
#[derive(Debug, Clone)]
pub struct BootstrapToken {
pub value: String,
pub expires_at: SystemTime,
}
#[derive(Debug, Clone)]
pub struct AppContext {
pub config: AppConfig,
pub storage: StudioStorage,
pub bootstrap_tokens: Arc<RwLock<HashMap<String, BootstrapToken>>>,
pub sessions: Arc<RwLock<HashMap<String, SessionInfo>>>,
pub source_roots: Arc<RwLock<Vec<SourceRootSummary>>>,
pub workspace_task_runs: Arc<RwLock<HashMap<Uuid, WorkspaceTaskRunRecord>>>,
pub workspace_file_change_previews:
Arc<RwLock<HashMap<Uuid, WorkspaceFileChangePreviewRecord>>>,
pub chat_events: broadcast::Sender<ChatEventEnvelope>,
}
#[derive(Debug, Clone)]
pub struct WorkspaceTaskRunRecord {
pub session_id: String,
pub summary: WorkspaceTaskRunSummary,
pub cancel_requested: Arc<AtomicBool>,
}
#[derive(Debug, Clone)]
pub struct WorkspaceFileChangePreviewRecord {
pub session_id: String,
pub request: WorkspaceFileChangePreviewRequest,
pub base_modified_at_ms: Option<u64>,
pub base_size_bytes: Option<u64>,
pub expires_at_ms: u64,
}
const MAX_ACTIVE_WORKSPACE_TASK_RUNS_PER_SESSION: usize = 1;
const WORKSPACE_TASK_RUN_RETENTION_MS: u64 = 30 * 60 * 1000;
const WORKSPACE_FILE_CHANGE_PREVIEW_TTL_MS: u64 = 10 * 60 * 1000;
const WORKSPACE_TASK_FAILED_EXIT_CODE: &str = "workspace_task_failed_exit";
const WORKSPACE_TASK_TIMED_OUT_CODE: &str = "workspace_task_timed_out";
impl AppContext {
pub fn new(config: AppConfig, storage: StudioStorage) -> Self {
let (chat_events, _) = broadcast::channel(128);
Self {
config,
storage,
bootstrap_tokens: Arc::new(RwLock::new(HashMap::new())),
sessions: Arc::new(RwLock::new(HashMap::new())),
source_roots: Arc::new(RwLock::new(Vec::new())),
workspace_task_runs: Arc::new(RwLock::new(HashMap::new())),
workspace_file_change_previews: Arc::new(RwLock::new(HashMap::new())),
chat_events,
}
}
pub fn issue_bootstrap_token(&self) -> String {
let token = Uuid::new_v4().to_string();
let entry = BootstrapToken {
value: token.clone(),
expires_at: SystemTime::now() + Duration::from_secs(300),
};
self.bootstrap_tokens
.write()
.expect("bootstrap token store poisoned")
.insert(token.clone(), entry);
token
}
pub fn consume_bootstrap_token(&self, token: &str) -> bool {
let mut store = self
.bootstrap_tokens
.write()
.expect("bootstrap token store poisoned");
let Some(entry) = store.remove(token) else {
return false;
};
entry.value == token && entry.expires_at > SystemTime::now()
}
pub async fn issue_session(&self) -> Result<SessionInfo> {
let session_id = Uuid::new_v4().to_string();
self.storage
.persist_session(&session_id)
.await
.with_context(|| format!("failed to persist issued session {session_id}"))?;
self.remember_session(session_id.clone())
.with_context(|| format!("generated session id should be valid UUID: {session_id}"))
}
pub fn lookup_session(&self, session_id: &str) -> Option<SessionInfo> {
self.sessions
.read()
.expect("session store poisoned")
.get(session_id)
.cloned()
}
pub fn remember_session(&self, session_id: impl Into<String>) -> Option<SessionInfo> {
let session_id = session_id.into();
if Uuid::parse_str(&session_id).is_err() {
return None;
}
if let Some(existing) = self.lookup_session(&session_id) {
return Some(existing);
}
let session = SessionInfo {
id: session_id.clone(),
};
self.sessions
.write()
.expect("session store poisoned")
.insert(session_id, session.clone());
Some(session)
}
pub fn provider_summaries(&self) -> Vec<ProviderSummary> {
vec![
ProviderSummary {
id: "ollama".to_string(),
label: "Ollama".to_string(),
endpoint_hint: "http://127.0.0.1:11434".to_string(),
last_test_ok: None,
last_test_detail: None,
last_tested_at: None,
},
ProviderSummary {
id: "lmstudio".to_string(),
label: "LM Studio".to_string(),
endpoint_hint: "http://127.0.0.1:1234".to_string(),
last_test_ok: None,
last_test_detail: None,
last_tested_at: None,
},
]
}
pub fn register_source_root(&self, summary: SourceRootSummary) -> SourceRootSummary {
let mut roots = self
.source_roots
.write()
.expect("source root store poisoned");
if let Some(existing) = roots.iter().find(|existing| existing.path == summary.path) {
return existing.clone();
}
roots.push(summary.clone());
summary
}
pub fn publish_chat_event(&self, event: ChatEventEnvelope) {
let _ = self.chat_events.send(event);
}
pub fn create_workspace_task_run(
&self,
session_id: &str,
input: &WorkspaceTaskRunRequest,
path: String,
) -> std::result::Result<(WorkspaceTaskRunSummary, Arc<AtomicBool>), WorkspaceTaskError> {
let mut runs = self
.workspace_task_runs
.write()
.expect("workspace task run store poisoned");
prune_finished_workspace_task_runs(&mut runs, current_time_ms());
let active_count = runs
.values()
.filter(|record| {
record.session_id == session_id
&& matches!(
record.summary.status,
WorkspaceTaskRunStatus::Queued | WorkspaceTaskRunStatus::Running
)
})
.count();
if active_count >= MAX_ACTIVE_WORKSPACE_TASK_RUNS_PER_SESSION {
return Err(WorkspaceTaskError::busy(
"workspace task run must wait for the active run to finish",
));
}
let run_id = Uuid::new_v4();
let cancel_requested = Arc::new(AtomicBool::new(false));
let summary = WorkspaceTaskRunSummary {
run_id,
task_id: input.task_id,
path,
status: WorkspaceTaskRunStatus::Running,
command_label: crate::workspace_tasks::workspace_task_command_label(input.task_id)
.to_string(),
exit_code: None,
stdout_tail: String::new(),
stderr_tail: String::new(),
stdout_truncated: false,
stderr_truncated: false,
timed_out: false,
cancel_requested: false,
started_at_ms: current_time_ms(),
completed_at_ms: None,
duration_ms: None,
error: None,
error_code: None,
max_output_bytes: crate::workspace_tasks::workspace_task_max_output_bytes(),
};
runs.insert(
run_id,
WorkspaceTaskRunRecord {
session_id: session_id.to_string(),
summary: summary.clone(),
cancel_requested: cancel_requested.clone(),
},
);
Ok((summary, cancel_requested))
}
pub fn list_workspace_task_runs(
&self,
session_id: &str,
error_code: Option<&str>,
) -> Vec<WorkspaceTaskRunSummary> {
let mut runs = self
.workspace_task_runs
.write()
.expect("workspace task run store poisoned");
prune_finished_workspace_task_runs(&mut runs, current_time_ms());
let mut summaries: Vec<_> = runs
.values()
.filter(|record| {
record.session_id == session_id
&& error_code
.is_none_or(|code| record.summary.error_code.as_deref() == Some(code))
})
.map(|record| record.summary.clone())
.collect();
summaries.sort_by_key(|summary| std::cmp::Reverse(summary.started_at_ms));
summaries
}
pub fn get_workspace_task_run(
&self,
session_id: &str,
run_id: Uuid,
) -> Option<WorkspaceTaskRunSummary> {
let mut runs = self
.workspace_task_runs
.write()
.expect("workspace task run store poisoned");
prune_finished_workspace_task_runs(&mut runs, current_time_ms());
runs.get(&run_id)
.filter(|record| record.session_id == session_id)
.map(|record| record.summary.clone())
}
pub fn remove_workspace_task_run(&self, session_id: &str, run_id: Uuid) {
let mut runs = self
.workspace_task_runs
.write()
.expect("workspace task run store poisoned");
let Some(record) = runs.get(&run_id) else {
return;
};
if record.session_id == session_id {
runs.remove(&run_id);
}
}
pub fn cancel_workspace_task_run(
&self,
session_id: &str,
run_id: Uuid,
) -> Option<WorkspaceTaskRunSummary> {
let mut runs = self
.workspace_task_runs
.write()
.expect("workspace task run store poisoned");
prune_finished_workspace_task_runs(&mut runs, current_time_ms());
let record = runs.get_mut(&run_id)?;
if record.session_id != session_id {
return None;
}
if matches!(
record.summary.status,
WorkspaceTaskRunStatus::Queued | WorkspaceTaskRunStatus::Running
) {
record.cancel_requested.store(true, Ordering::SeqCst);
record.summary.cancel_requested = true;
}
Some(record.summary.clone())
}
pub fn finish_workspace_task_run(
&self,
session_id: &str,
run_id: Uuid,
result: std::result::Result<WorkspaceTaskRunResponse, WorkspaceTaskError>,
) -> Option<WorkspaceTaskRunSummary> {
let mut runs = self
.workspace_task_runs
.write()
.expect("workspace task run store poisoned");
let record = runs.get_mut(&run_id)?;
if record.session_id != session_id {
return None;
}
let completed_at_ms = current_time_ms();
match result {
Ok(response) => {
let failed_exit = !matches!(response.exit_code, Some(0));
record.summary.status = if response.cancelled {
WorkspaceTaskRunStatus::Cancelled
} else if response.timed_out {
WorkspaceTaskRunStatus::TimedOut
} else if failed_exit {
WorkspaceTaskRunStatus::Failed
} else {
WorkspaceTaskRunStatus::Complete
};
record.summary.path = response.path;
record.summary.command_label = response.command_label;
record.summary.exit_code = response.exit_code;
record.summary.stdout_tail = response.stdout;
record.summary.stderr_tail = response.stderr;
record.summary.stdout_truncated = response.stdout_truncated;
record.summary.stderr_truncated = response.stderr_truncated;
record.summary.timed_out = response.timed_out;
record.summary.cancel_requested =
record.summary.cancel_requested || response.cancelled;
record.summary.completed_at_ms = Some(completed_at_ms);
record.summary.duration_ms = Some(response.duration_ms);
let (error, error_code) = if response.cancelled {
(None, None)
} else if response.timed_out {
(
Some("workspace task timed out".to_string()),
Some(WORKSPACE_TASK_TIMED_OUT_CODE.to_string()),
)
} else if failed_exit {
(
Some(match response.exit_code {
Some(code) => format!("workspace task exited with code {code}"),
None => "workspace task ended without an exit code".to_string(),
}),
Some(WORKSPACE_TASK_FAILED_EXIT_CODE.to_string()),
)
} else {
(None, None)
};
record.summary.error = error;
record.summary.error_code = error_code;
record.summary.max_output_bytes = response.max_output_bytes;
}
Err(error) => {
record.summary.status = WorkspaceTaskRunStatus::Failed;
record.summary.completed_at_ms = Some(completed_at_ms);
record.summary.duration_ms =
Some(completed_at_ms.saturating_sub(record.summary.started_at_ms));
record.summary.error_code = Some(error.api_code().to_string());
record.summary.error = Some(error.to_string());
}
}
Some(record.summary.clone())
}
pub fn register_workspace_file_change_preview(
&self,
session_id: &str,
preview_token: Uuid,
expires_at_ms: u64,
request: WorkspaceFileChangePreviewRequest,
base_modified_at_ms: Option<u64>,
base_size_bytes: Option<u64>,
) {
let mut previews = self
.workspace_file_change_previews
.write()
.expect("workspace file change preview store poisoned");
prune_workspace_file_change_previews(&mut previews, current_time_ms());
previews.insert(
preview_token,
WorkspaceFileChangePreviewRecord {
session_id: session_id.to_string(),
request,
base_modified_at_ms,
base_size_bytes,
expires_at_ms,
},
);
}
pub fn new_workspace_file_change_preview_token(&self) -> (Uuid, u64) {
(
Uuid::new_v4(),
current_time_ms().saturating_add(WORKSPACE_FILE_CHANGE_PREVIEW_TTL_MS),
)
}
pub fn consume_workspace_file_change_preview(
&self,
session_id: &str,
preview_token: Uuid,
) -> std::result::Result<WorkspaceFileChangePreviewRecord, WorkspaceError> {
let mut previews = self
.workspace_file_change_previews
.write()
.expect("workspace file change preview store poisoned");
prune_workspace_file_change_previews(&mut previews, current_time_ms());
let Some(record) = previews.remove(&preview_token) else {
return Err(WorkspaceError::preview_missing(
"workspace file change preview missing or stale",
));
};
if record.session_id != session_id {
previews.insert(preview_token, record);
return Err(WorkspaceError::preview_missing(
"workspace file change preview missing or stale",
));
}
Ok(record)
}
}
fn current_time_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}
fn prune_finished_workspace_task_runs(
runs: &mut HashMap<Uuid, WorkspaceTaskRunRecord>,
now_ms: u64,
) {
runs.retain(|_, record| {
if matches!(
record.summary.status,
WorkspaceTaskRunStatus::Queued | WorkspaceTaskRunStatus::Running
) {
return true;
}
let Some(completed_at_ms) = record.summary.completed_at_ms else {
return true;
};
now_ms.saturating_sub(completed_at_ms) <= WORKSPACE_TASK_RUN_RETENTION_MS
});
}
fn prune_workspace_file_change_previews(
previews: &mut HashMap<Uuid, WorkspaceFileChangePreviewRecord>,
now_ms: u64,
) {
previews.retain(|_, record| record.expires_at_ms > now_ms);
}
pub struct PreparedServer {
config: AppConfig,
bootstrap_url: String,
listener: TcpListener,
raw_service: RawIngressService<()>,
}
impl PreparedServer {
pub fn bootstrap_url(&self) -> &str {
&self.bootstrap_url
}
pub async fn run(self) -> Result<()> {
let bind_addr = self.config.bind_addr.clone();
let listener = self.listener;
let raw_service = self.raw_service;
info!("starting Soma Studio server on {}", bind_addr);
loop {
tokio::select! {
accept_result = listener.accept() => {
let (stream, _) = accept_result.context("failed to accept TCP connection")?;
let io = TokioIo::new(stream);
let service = raw_service.clone();
tokio::spawn(async move {
let builder = AutoBuilder::new(TokioExecutor::new());
if let Err(error) = builder.serve_connection(io, service).await {
warn!("connection handling failed: {error}");
}
});
}
signal = tokio::signal::ctrl_c() => {
if let Err(error) = signal {
warn!("failed to listen for Ctrl+C: {error}");
}
break;
}
}
}
Ok(())
}
}
pub async fn prepare_server(mut config: AppConfig) -> Result<PreparedServer> {
config.ensure_directories()?;
if !config.web_shell_file.exists()
&& let Some(extracted_dir) = ensure_embedded_web_assets(&config)?
{
config = config.with_web_build_dir(extracted_dir);
}
if !config.web_shell_file.exists() {
anyhow::bail!(
"web shell not found at {}. Set SOMA_STUDIO_WEB_DIR or build the web app first.",
config.web_shell_file.display()
);
}
let bind_addr = config.bind_socket_addr()?;
let listener = TcpListener::bind(bind_addr)
.await
.with_context(|| format!("failed to bind {}", config.bind_addr))?;
let local_addr = listener
.local_addr()
.context("failed to inspect bound local address")?;
config = config.with_bind_addr(local_addr.to_string());
let storage = StudioStorage::open(&config).await?;
let persisted_source_roots = storage.list_source_roots().await?;
let context = AppContext::new(config.clone(), storage);
*context
.source_roots
.write()
.expect("source root store poisoned") = persisted_source_roots;
let bootstrap_token = context.issue_bootstrap_token();
let bootstrap_url = format!(
"http://{}/bootstrap?token={}",
config.bind_addr, bootstrap_token
);
let raw_service = build_raw_service(&context);
Ok(PreparedServer {
config,
bootstrap_url,
listener,
raw_service,
})
}
pub fn embedded_web_asset_count() -> usize {
embedded_assets::EMBEDDED_WEB_ASSETS.len()
}
pub fn embedded_web_shell_available() -> bool {
embedded_assets::EMBEDDED_WEB_ASSETS
.iter()
.any(|asset| asset.path == "spa.html")
}
fn build_raw_service(context: &AppContext) -> RawIngressService<()> {
build_ingress(context).into_raw_service(())
}
fn build_ingress(context: &AppContext) -> HttpIngress<()> {
let build_dir = context.config.web_build_dir.clone();
let shell_file = context.config.web_shell_file.clone();
let user_assets_dir = context.config.user_assets_dir.clone();
let app_context = context.clone();
Ranvier::http::<()>()
.bus_injector(move |parts, bus| {
let origin = parts
.headers
.get("origin")
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
let host = parts
.headers
.get("host")
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
let path = parts.uri.path().to_string();
bus.insert(app_context.clone());
bus.insert(CookieJar::from_parts(parts));
bus.insert(RequestOrigin(origin));
bus.insert(RequestHost(host));
bus.insert(RequestPath(path));
})
.serve_assets(
"/assets",
StaticAssetSource::directory(user_assets_dir.to_string_lossy().to_string()),
StaticAssetPolicy::public_assets()
.compression()
.enable_range_requests(),
)
.serve_assets(
"/",
StaticAssetSource::directory(build_dir.to_string_lossy().to_string()),
StaticAssetPolicy::public_assets()
.compression()
.serve_precompressed()
.enable_range_requests()
.directory_index("index.html"),
)
.serve_spa_shell(
StaticShell::file(shell_file.to_string_lossy().to_string())
.cache_control("no-store")
.compression()
.exclude_prefix("/api")
.exclude_prefix("/assets")
.exclude_prefix("/bootstrap"),
)
.get("/bootstrap", bootstrap_redirect_axon())
.get_json_out("/api/health", health_axon())
.get_with_error(
"/api/providers/models",
provider_models_axon(),
provider_models_error_response,
)
.group("/api", |g| {
g.guard(crate::transitions::RequireSessionGuard)
.get_json_out("/app/init", init_axon())
.get_json_out("/providers", providers_axon())
.get_json_out("/providers/selection", provider_selection_get_axon())
.get_json_out("/source-roots", source_roots_list_axon())
.get("/workspace/files", workspace_files_axon())
.get("/workspace/file-preview", workspace_file_preview_axon())
.get_json_out(
"/workspace/file-change-audits",
workspace_file_change_audits_list_axon(),
)
.get_json_out("/workspace/task-runs", workspace_task_runs_list_axon())
.get("/workspace/task-runs/:id", workspace_task_run_get_axon())
.get_json_out("/ingest/jobs", ingest_jobs_axon())
.get_json_out("/ingest/status", ingest_status_axon())
.get("/search", search_axon())
.get_json_out("/search/index/status", search_index_status_axon())
.get_json_out("/search/open-action", search_open_action_axon())
.get_json_out("/conversations", conversations_list_axon())
.get_json_out("/conversations/:id/messages", conversation_messages_axon())
.get("/notebook/tree", notebook_notes_list_axon())
.get("/notebook/note", notebook_note_read_axon())
.get("/notebook/search", notebook_notes_search_axon())
.get("/notebook/adapters", notebook_adapters_list_axon())
.get("/notebook/index", notebook_index_get_axon())
.get("/notebook/chunks", notebook_chunks_get_axon())
.get("/notebook/embeddings", notebook_embeddings_get_axon())
.get("/notebook/retrieve", notebook_retrieve_axon())
.get("/notebook/artifact", notebook_artifact_axon())
.get("/stream/chat", stream_chat_axon())
.group("", |g| {
g.guard(crate::transitions::RequireSameOriginGuard)
.post_typed("/providers/selection", provider_selection_set_axon())
.post_typed("/source-roots", source_roots_create_axon())
.post_typed(
"/workspace/source-root",
workspace_source_root_create_axon(),
)
.post_typed(
"/workspace/file-change-preview",
workspace_file_change_preview_axon(),
)
.post_typed(
"/workspace/file-change-apply",
workspace_file_change_apply_axon(),
)
.post_typed("/workspace/task", workspace_task_run_axon())
.post_typed("/workspace/task-runs", workspace_task_run_start_axon())
.post(
"/workspace/task-runs/:id/cancel",
workspace_task_run_cancel_axon(),
)
.post_typed("/ingest/rescan", ingest_rescan_axon())
.post("/search/index/rebuild", search_index_rebuild_axon())
.post("/search/index/sync", search_index_sync_axon())
.post("/search/index/recover", search_index_recover_axon())
.post_typed_json_out("/conversations", conversations_create_axon())
.delete_json_out("/conversations/:id", conversation_delete_axon())
.post_typed("/notebook/note", notebook_note_create_axon())
.put_typed("/notebook/note", notebook_note_write_axon())
.post_typed("/notebook/render", notebook_note_render_axon())
.post("/notebook/index", notebook_index_build_axon())
.post("/notebook/chunks", notebook_chunks_build_axon())
.post("/notebook/embeddings", notebook_embeddings_build_axon())
.post_typed_json_out("/chat/send", chat_send_axon())
.post_typed("/providers/test", provider_test_axon())
})
})
}
fn ensure_embedded_web_assets(config: &AppConfig) -> Result<Option<PathBuf>> {
if embedded_assets::EMBEDDED_WEB_ASSETS.is_empty() {
return Ok(None);
}
let runtime_web_dir = config.data_dir.join("runtime-web");
for asset in embedded_assets::EMBEDDED_WEB_ASSETS {
let target = runtime_web_dir.join(asset.path);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(&target, asset.bytes)
.with_context(|| format!("failed to write {}", target.display()))?;
}
Ok(Some(runtime_web_dir))
}
#[cfg(test)]
mod tests {
use crate::storage::StudioStorage;
use http::StatusCode;
use ranvier::http::{TestApp, TestRequest};
use serde_json::json;
use soma_studio_core::AppConfig;
use soma_studio_core::{
ApiErrorResponse, ChatSendResponse, ConversationDeleteResponse, ConversationMessage,
ConversationSummary, IngestJobSummary, IngestStatusResponse, NotebookAdapterStatus,
NotebookChunkResponse, NotebookIndexResponse, NotebookNoteContent, NotebookNoteFormat,
NotebookNoteSummary, NotebookRenderResponse, NotebookRetrievalResponse,
NotebookSearchResult, ProviderSelectionResponse, ProviderTestResponse, SearchFieldScope,
SearchIndexRebuildResponse, SearchIndexStatusResponse, SearchOpenActionResponse,
SearchResponse, SearchSort, SearchSourceType, WorkspaceEntryKind,
WorkspaceFileChangeAction, WorkspaceFileChangeApplyResponse, WorkspaceFileChangeAuditEntry,
WorkspaceFileChangeAuditStatus, WorkspaceFileChangePreviewRequest,
WorkspaceFileChangePreviewResponse, WorkspaceFileListResponse,
WorkspaceFilePreviewResponse, WorkspaceTaskRunResponse, WorkspaceTaskRunStatus,
WorkspaceTaskRunSummary,
};
use uuid::Uuid;
use super::{AppContext, SourceRootSummary, build_ingress};
#[test]
fn bootstrap_token_is_single_use() {
let config = AppConfig::from_env().expect("config");
let runtime = tokio::runtime::Runtime::new().expect("runtime");
let storage = runtime.block_on(async {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = AppConfig {
app_name: "Soma Studio".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
project_root: temp_dir.clone(),
data_dir: temp_dir.clone(),
derived_dir: temp_dir.join("derived"),
notebook_dir: temp_dir.join("notebook"),
user_assets_dir: temp_dir.join("assets"),
db_path: temp_dir.join("test.db"),
web_build_dir: temp_dir.join("web"),
web_shell_file: temp_dir.join("web").join("spa.html"),
};
StudioStorage::open(&config).await.expect("storage")
});
let context = AppContext::new(config, storage);
let token = context.issue_bootstrap_token();
assert!(context.consume_bootstrap_token(&token));
assert!(!context.consume_bootstrap_token(&token));
}
#[tokio::test]
async fn bootstrap_route_sets_persistent_session_cookie() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-bootstrap-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = AppConfig {
app_name: "Soma Studio".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
project_root: temp_dir.clone(),
data_dir: temp_dir.clone(),
derived_dir: temp_dir.join("derived"),
notebook_dir: temp_dir.join("notebook"),
user_assets_dir: temp_dir.join("assets"),
db_path: temp_dir.join("test.db"),
web_build_dir: temp_dir.join("web"),
web_shell_file: temp_dir.join("web").join("spa.html"),
};
let storage = StudioStorage::open(&config).await.expect("storage");
let context = AppContext::new(config, storage);
let token = context.issue_bootstrap_token();
let app = TestApp::new(build_ingress(&context), ());
let response = app
.send(TestRequest::get(format!("/bootstrap?token={token}")))
.await
.expect("bootstrap response");
assert_eq!(response.status(), StatusCode::FOUND);
assert_eq!(
response
.header("location")
.and_then(|value| value.to_str().ok()),
Some("/")
);
let set_cookie = response
.header("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("set-cookie");
assert!(set_cookie.contains("soma_studio_session="));
assert!(set_cookie.contains("Max-Age=2592000"));
assert!(set_cookie.contains("HttpOnly"));
}
#[test]
fn register_source_root_returns_existing_summary_for_duplicates() {
let config = AppConfig::from_env().expect("config");
let runtime = tokio::runtime::Runtime::new().expect("runtime");
let storage = runtime.block_on(async {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = AppConfig {
app_name: "Soma Studio".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
project_root: temp_dir.clone(),
data_dir: temp_dir.clone(),
derived_dir: temp_dir.join("derived"),
notebook_dir: temp_dir.join("notebook"),
user_assets_dir: temp_dir.join("assets"),
db_path: temp_dir.join("test.db"),
web_build_dir: temp_dir.join("web"),
web_shell_file: temp_dir.join("web").join("spa.html"),
};
StudioStorage::open(&config).await.expect("storage")
});
let context = AppContext::new(config, storage);
let first = SourceRootSummary {
id: Uuid::new_v4(),
path: "F:/docs".to_string(),
read_only: true,
};
let second = SourceRootSummary {
id: Uuid::new_v4(),
path: "F:/docs".to_string(),
read_only: true,
};
let inserted = context.register_source_root(first.clone());
let duplicate = context.register_source_root(second);
assert_eq!(inserted.id, duplicate.id);
assert_eq!(context.source_roots.read().expect("source roots").len(), 1);
}
#[test]
fn remember_session_rehydrates_valid_uuid() {
let config = AppConfig::from_env().expect("config");
let runtime = tokio::runtime::Runtime::new().expect("runtime");
let storage = runtime.block_on(async {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = AppConfig {
app_name: "Soma Studio".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
project_root: temp_dir.clone(),
data_dir: temp_dir.clone(),
derived_dir: temp_dir.join("derived"),
notebook_dir: temp_dir.join("notebook"),
user_assets_dir: temp_dir.join("assets"),
db_path: temp_dir.join("test.db"),
web_build_dir: temp_dir.join("web"),
web_shell_file: temp_dir.join("web").join("spa.html"),
};
StudioStorage::open(&config).await.expect("storage")
});
let context = AppContext::new(config, storage);
let session_id = Uuid::new_v4().to_string();
assert!(context.lookup_session(&session_id).is_none());
let restored = context
.remember_session(session_id.clone())
.expect("valid session id");
assert_eq!(restored.id, session_id);
assert_eq!(
context
.lookup_session(&session_id)
.expect("session should exist")
.id,
session_id
);
}
#[test]
fn remember_session_rejects_invalid_identifier() {
let config = AppConfig::from_env().expect("config");
let runtime = tokio::runtime::Runtime::new().expect("runtime");
let storage = runtime.block_on(async {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = AppConfig {
app_name: "Soma Studio".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
project_root: temp_dir.clone(),
data_dir: temp_dir.clone(),
derived_dir: temp_dir.join("derived"),
notebook_dir: temp_dir.join("notebook"),
user_assets_dir: temp_dir.join("assets"),
db_path: temp_dir.join("test.db"),
web_build_dir: temp_dir.join("web"),
web_shell_file: temp_dir.join("web").join("spa.html"),
};
StudioStorage::open(&config).await.expect("storage")
});
let context = AppContext::new(config, storage);
assert!(context.remember_session("not-a-uuid").is_none());
assert!(context.lookup_session("not-a-uuid").is_none());
}
#[test]
fn workspace_file_change_preview_token_survives_wrong_session_attempt() {
let config = AppConfig::from_env().expect("config");
let runtime = tokio::runtime::Runtime::new().expect("runtime");
let storage = runtime.block_on(async {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = AppConfig {
app_name: "Soma Studio".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
project_root: temp_dir.clone(),
data_dir: temp_dir.clone(),
derived_dir: temp_dir.join("derived"),
notebook_dir: temp_dir.join("notebook"),
user_assets_dir: temp_dir.join("assets"),
db_path: temp_dir.join("test.db"),
web_build_dir: temp_dir.join("web"),
web_shell_file: temp_dir.join("web").join("spa.html"),
};
StudioStorage::open(&config).await.expect("storage")
});
let context = AppContext::new(config, storage);
let session_id = Uuid::new_v4().to_string();
let other_session_id = Uuid::new_v4().to_string();
let (preview_token, expires_at_ms) = context.new_workspace_file_change_preview_token();
let request = WorkspaceFileChangePreviewRequest {
action: WorkspaceFileChangeAction::WriteText,
path: "docs/new.md".to_string(),
target_path: None,
content: Some("# New\n".to_string()),
expected_modified_at_ms: None,
};
context.register_workspace_file_change_preview(
&session_id,
preview_token,
expires_at_ms,
request,
None,
None,
);
assert!(
context
.consume_workspace_file_change_preview(&other_session_id, preview_token)
.is_err()
);
let record = context
.consume_workspace_file_change_preview(&session_id, preview_token)
.expect("original session should still own the preview");
assert_eq!(record.session_id, session_id);
}
#[tokio::test]
async fn workspace_files_route_lists_project_root_with_escape_guard() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-workspace-http-{}", Uuid::new_v4()));
std::fs::create_dir_all(temp_dir.join("docs")).expect("docs dir");
std::fs::create_dir_all(temp_dir.join("src")).expect("src dir");
std::fs::write(temp_dir.join("README.md"), "# Readme").expect("readme");
std::fs::write(temp_dir.join("docs").join("guide.md"), "# Guide").expect("guide");
std::fs::write(
temp_dir.join("Cargo.toml"),
"[package]\nname = \"soma-studio-route-test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.expect("cargo manifest");
std::fs::write(
temp_dir.join("src").join("lib.rs"),
"pub fn ok() -> bool { true }\n",
)
.expect("cargo lib");
let git_init = std::process::Command::new("git")
.arg("init")
.arg("--quiet")
.current_dir(&temp_dir)
.output()
.expect("run git init");
assert!(
git_init.status.success(),
"git init failed: {}",
String::from_utf8_lossy(&git_init.stderr)
);
let config = AppConfig {
app_name: "Soma Studio".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
project_root: temp_dir.clone(),
data_dir: temp_dir.clone(),
derived_dir: temp_dir.join("derived"),
notebook_dir: temp_dir.join("notebook"),
user_assets_dir: temp_dir.join("assets"),
db_path: temp_dir.join("test.db"),
web_build_dir: temp_dir.join("web"),
web_shell_file: temp_dir.join("web").join("spa.html"),
};
let storage = StudioStorage::open(&config).await.expect("storage");
let context = AppContext::new(config, storage);
let session = context.issue_session().await.expect("session");
let cookie = format!("soma_studio_session={}", session.id);
let app = TestApp::new(build_ingress(&context), ());
let root_response = app
.send(TestRequest::get("/api/workspace/files").header("cookie", &cookie))
.await
.expect("workspace root response");
assert_eq!(root_response.status(), StatusCode::OK);
let root: WorkspaceFileListResponse = root_response.json().expect("workspace root payload");
assert_eq!(root.path, "");
assert_eq!(root.parent_path, None);
assert!(
root.entries
.iter()
.any(|entry| entry.name == "docs" && entry.kind == WorkspaceEntryKind::Directory)
);
assert!(
root.entries
.iter()
.any(|entry| entry.name == "README.md" && entry.kind == WorkspaceEntryKind::File)
);
let docs_response = app
.send(TestRequest::get("/api/workspace/files?path=docs").header("cookie", &cookie))
.await
.expect("workspace docs response");
assert_eq!(docs_response.status(), StatusCode::OK);
let docs: WorkspaceFileListResponse = docs_response.json().expect("workspace docs payload");
assert_eq!(docs.path, "docs");
assert_eq!(docs.parent_path.as_deref(), Some(""));
assert_eq!(docs.entries.len(), 1);
assert_eq!(docs.entries[0].path, "docs/guide.md");
let preview_response = app
.send(
TestRequest::get("/api/workspace/file-preview?path=docs%2Fguide.md")
.header("cookie", &cookie),
)
.await
.expect("workspace preview response");
assert_eq!(preview_response.status(), StatusCode::OK);
let preview: WorkspaceFilePreviewResponse =
preview_response.json().expect("workspace preview payload");
assert_eq!(preview.path, "docs/guide.md");
assert_eq!(preview.content, "# Guide");
assert!(!preview.truncated);
let change_preview_response = app
.send(
TestRequest::post("/api/workspace/file-change-preview")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"action": "write_text",
"path": "docs/new.md",
"content": "# New\n",
"expected_modified_at_ms": null
}))
.expect("workspace file change preview request"),
)
.await
.expect("workspace file change preview response");
assert_eq!(change_preview_response.status(), StatusCode::OK);
let change_preview: WorkspaceFileChangePreviewResponse = change_preview_response
.json()
.expect("workspace file change preview payload");
assert_eq!(change_preview.path, "docs/new.md");
assert!(!change_preview.exists_before);
assert_eq!(change_preview.size_bytes_after, Some(6));
assert!(change_preview.diff_preview.contains("+# New"));
assert!(!temp_dir.join("docs").join("new.md").exists());
let change_apply_response = app
.send(
TestRequest::post("/api/workspace/file-change-apply")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "preview_token": change_preview.preview_token }))
.expect("workspace file change apply request"),
)
.await
.expect("workspace file change apply response");
assert_eq!(change_apply_response.status(), StatusCode::OK);
let change_apply: WorkspaceFileChangeApplyResponse = change_apply_response
.json()
.expect("workspace file change apply payload");
assert!(change_apply.applied);
assert!(change_apply.exists_after);
assert_eq!(
std::fs::read_to_string(temp_dir.join("docs").join("new.md")).expect("new file"),
"# New\n"
);
let stale_apply_response = app
.send(
TestRequest::post("/api/workspace/file-change-apply")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "preview_token": change_preview.preview_token }))
.expect("stale workspace file change apply request"),
)
.await
.expect("stale workspace file change apply response");
assert_eq!(stale_apply_response.status(), StatusCode::NOT_FOUND);
let stale_apply_error: ApiErrorResponse = stale_apply_response
.json()
.expect("stale workspace file change apply error");
assert_eq!(
stale_apply_error.code,
"workspace_file_change_preview_missing"
);
assert_eq!(stale_apply_error.status, StatusCode::NOT_FOUND.as_u16());
let rename_preview_response = app
.send(
TestRequest::post("/api/workspace/file-change-preview")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"action": "rename_path",
"path": "docs/new.md",
"target_path": "docs/renamed.md",
"content": null,
"expected_modified_at_ms": change_apply.modified_at_ms_after
}))
.expect("workspace file rename preview request"),
)
.await
.expect("workspace file rename preview response");
assert_eq!(rename_preview_response.status(), StatusCode::OK);
let rename_preview: WorkspaceFileChangePreviewResponse = rename_preview_response
.json()
.expect("workspace file rename preview payload");
assert_eq!(rename_preview.path, "docs/new.md");
assert_eq!(
rename_preview.target_path.as_deref(),
Some("docs/renamed.md")
);
assert_eq!(rename_preview.size_bytes_after, Some(6));
assert!(
rename_preview
.diff_preview
.contains("rename from docs/new.md")
);
assert!(
rename_preview
.diff_preview
.contains("rename to docs/renamed.md")
);
assert!(!temp_dir.join("docs").join("renamed.md").exists());
std::fs::write(temp_dir.join("docs").join("renamed.md"), "# Existing\n")
.expect("race rename target");
let rename_race_apply_response = app
.send(
TestRequest::post("/api/workspace/file-change-apply")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "preview_token": rename_preview.preview_token }))
.expect("workspace file rename race apply request"),
)
.await
.expect("workspace file rename race apply response");
assert_eq!(rename_race_apply_response.status(), StatusCode::CONFLICT);
let rename_race_error: ApiErrorResponse = rename_race_apply_response
.json()
.expect("workspace file rename race error");
assert_eq!(rename_race_error.code, "workspace_conflict");
assert_eq!(rename_race_error.status, StatusCode::CONFLICT.as_u16());
assert_eq!(
std::fs::read_to_string(temp_dir.join("docs").join("new.md")).expect("new file"),
"# New\n"
);
assert_eq!(
std::fs::read_to_string(temp_dir.join("docs").join("renamed.md"))
.expect("existing rename target"),
"# Existing\n"
);
std::fs::remove_file(temp_dir.join("docs").join("renamed.md"))
.expect("remove race rename target");
let rename_preview_response = app
.send(
TestRequest::post("/api/workspace/file-change-preview")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"action": "rename_path",
"path": "docs/new.md",
"target_path": "docs/renamed.md",
"content": null,
"expected_modified_at_ms": change_apply.modified_at_ms_after
}))
.expect("workspace file rename preview request"),
)
.await
.expect("workspace file rename preview response");
assert_eq!(rename_preview_response.status(), StatusCode::OK);
let rename_preview: WorkspaceFileChangePreviewResponse = rename_preview_response
.json()
.expect("workspace file rename preview payload");
let rename_apply_response = app
.send(
TestRequest::post("/api/workspace/file-change-apply")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "preview_token": rename_preview.preview_token }))
.expect("workspace file rename apply request"),
)
.await
.expect("workspace file rename apply response");
assert_eq!(rename_apply_response.status(), StatusCode::OK);
let rename_apply: WorkspaceFileChangeApplyResponse = rename_apply_response
.json()
.expect("workspace file rename apply payload");
assert!(rename_apply.applied);
assert!(rename_apply.exists_after);
assert_eq!(rename_apply.target_path.as_deref(), Some("docs/renamed.md"));
assert!(!temp_dir.join("docs").join("new.md").exists());
assert_eq!(
std::fs::read_to_string(temp_dir.join("docs").join("renamed.md"))
.expect("renamed file"),
"# New\n"
);
let rename_overwrite_response = app
.send(
TestRequest::post("/api/workspace/file-change-preview")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"action": "rename_path",
"path": "docs/renamed.md",
"target_path": "docs/guide.md",
"content": null,
"expected_modified_at_ms": rename_apply.modified_at_ms_after
}))
.expect("workspace file rename overwrite preview request"),
)
.await
.expect("workspace file rename overwrite preview response");
assert_eq!(rename_overwrite_response.status(), StatusCode::CONFLICT);
let delete_preview_response = app
.send(
TestRequest::post("/api/workspace/file-change-preview")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"action": "delete_file",
"path": "docs/renamed.md",
"content": null,
"expected_modified_at_ms": rename_apply.modified_at_ms_after
}))
.expect("workspace file delete preview request"),
)
.await
.expect("workspace file delete preview response");
assert_eq!(delete_preview_response.status(), StatusCode::OK);
let delete_preview: WorkspaceFileChangePreviewResponse = delete_preview_response
.json()
.expect("workspace file delete preview payload");
assert!(delete_preview.exists_before);
assert!(delete_preview.diff_preview.contains("-# New"));
let delete_apply_response = app
.send(
TestRequest::post("/api/workspace/file-change-apply")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "preview_token": delete_preview.preview_token }))
.expect("workspace file delete apply request"),
)
.await
.expect("workspace file delete apply response");
assert_eq!(delete_apply_response.status(), StatusCode::OK);
let delete_apply: WorkspaceFileChangeApplyResponse = delete_apply_response
.json()
.expect("workspace file delete apply payload");
assert!(delete_apply.applied);
assert!(!delete_apply.exists_after);
assert!(!temp_dir.join("docs").join("renamed.md").exists());
let file_change_audits_response = app
.send(TestRequest::get("/api/workspace/file-change-audits").header("cookie", &cookie))
.await
.expect("workspace file change audits response");
assert_eq!(file_change_audits_response.status(), StatusCode::OK);
let file_change_audits: Vec<WorkspaceFileChangeAuditEntry> = file_change_audits_response
.json()
.expect("workspace file change audits payload");
let audit_json = serde_json::to_string(&file_change_audits).expect("audit json");
assert_eq!(file_change_audits.len(), 4);
assert_eq!(
file_change_audits
.iter()
.filter(|audit| audit.status == WorkspaceFileChangeAuditStatus::Complete)
.count(),
3
);
assert_eq!(
file_change_audits
.iter()
.filter(|audit| audit.status == WorkspaceFileChangeAuditStatus::Failed)
.count(),
1
);
assert!(file_change_audits.iter().any(|audit| {
audit.action == WorkspaceFileChangeAction::RenamePath
&& audit.status == WorkspaceFileChangeAuditStatus::Failed
&& audit.error_code.as_deref() == Some("workspace_conflict")
&& audit
.error
.as_deref()
.is_some_and(|error| error.contains("already exists"))
}));
let conflict_file_change_audits_response = app
.send(
TestRequest::get("/api/workspace/file-change-audits?error_code=workspace_conflict")
.header("cookie", &cookie),
)
.await
.expect("workspace conflict file change audits response");
assert_eq!(
conflict_file_change_audits_response.status(),
StatusCode::OK
);
let conflict_file_change_audits: Vec<WorkspaceFileChangeAuditEntry> =
conflict_file_change_audits_response
.json()
.expect("workspace conflict file change audits payload");
assert_eq!(conflict_file_change_audits.len(), 1);
assert_eq!(
conflict_file_change_audits[0].error_code.as_deref(),
Some("workspace_conflict")
);
assert!(!audit_json.contains("# New"));
assert!(!audit_json.contains("diff_preview"));
let unsafe_change_preview_response = app
.send(
TestRequest::post("/api/workspace/file-change-preview")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"action": "write_text",
"path": "..",
"content": "outside",
"expected_modified_at_ms": null
}))
.expect("unsafe workspace file change preview request"),
)
.await
.expect("unsafe workspace file change preview response");
assert_eq!(
unsafe_change_preview_response.status(),
StatusCode::BAD_REQUEST
);
let unsafe_change_error: ApiErrorResponse = unsafe_change_preview_response
.json()
.expect("unsafe workspace file change preview error");
assert_eq!(unsafe_change_error.code, "invalid_request");
assert_eq!(unsafe_change_error.status, StatusCode::BAD_REQUEST.as_u16());
let directory_preview_response = app
.send(
TestRequest::get("/api/workspace/file-preview?path=docs").header("cookie", &cookie),
)
.await
.expect("workspace directory preview response");
assert_eq!(directory_preview_response.status(), StatusCode::BAD_REQUEST);
let source_root_response = app
.send(
TestRequest::post("/api/workspace/source-root")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "path": "docs" }))
.expect("workspace source root request"),
)
.await
.expect("workspace source root response");
assert_eq!(source_root_response.status(), StatusCode::OK);
let source_root: SourceRootSummary = source_root_response
.json()
.expect("workspace source root payload");
assert!(source_root.read_only);
assert!(source_root.path.replace('\\', "/").ends_with("/docs"));
let source_root_rescan_response = app
.send(
TestRequest::post("/api/ingest/rescan")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "source_root_id": source_root.id }))
.expect("workspace source root rescan request"),
)
.await
.expect("workspace source root rescan response");
assert_eq!(source_root_rescan_response.status(), StatusCode::OK);
let source_root_rescan: IngestJobSummary = source_root_rescan_response
.json()
.expect("workspace source root rescan payload");
assert_eq!(source_root_rescan.status, "complete");
assert_eq!(source_root_rescan.file_count, 1);
let workspace_task_response = app
.send(
TestRequest::post("/api/workspace/task")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "task_id": "git_status" }))
.expect("workspace task request"),
)
.await
.expect("workspace task response");
assert_eq!(workspace_task_response.status(), StatusCode::OK);
let workspace_task: WorkspaceTaskRunResponse = workspace_task_response
.json()
.expect("workspace task payload");
assert_eq!(workspace_task.path, ".");
assert_eq!(workspace_task.command_label, "git status --short --branch");
assert!(!workspace_task.timed_out);
assert!(!workspace_task.cancelled);
assert_eq!(workspace_task.exit_code, Some(0));
assert!(workspace_task.stdout.contains("?? README.md"));
assert_eq!(workspace_task.max_output_bytes, 64 * 1024);
let inline_long_task_response = app
.send(
TestRequest::post("/api/workspace/task")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "task_id": "cargo_check" }))
.expect("inline long workspace task request"),
)
.await
.expect("inline long workspace task response");
assert_eq!(inline_long_task_response.status(), StatusCode::BAD_REQUEST);
let inline_long_task_error: ApiErrorResponse = inline_long_task_response
.json()
.expect("inline long workspace task error");
assert_eq!(inline_long_task_error.code, "invalid_request");
assert_eq!(
inline_long_task_error.status,
StatusCode::BAD_REQUEST.as_u16()
);
let task_run_start_response = app
.send(
TestRequest::post("/api/workspace/task-runs")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "task_id": "git_status" }))
.expect("workspace task run start request"),
)
.await
.expect("workspace task run start response");
assert_eq!(task_run_start_response.status(), StatusCode::OK);
let task_run: WorkspaceTaskRunSummary = task_run_start_response
.json()
.expect("workspace task run start payload");
assert_eq!(task_run.path, ".");
assert_eq!(task_run.status, WorkspaceTaskRunStatus::Running);
assert_eq!(task_run.command_label, "git status --short --branch");
assert_eq!(task_run.exit_code, None);
let mut completed_task_run = None;
for _ in 0..50 {
let task_run_response = app
.send(
TestRequest::get(format!("/api/workspace/task-runs/{}", task_run.run_id))
.header("cookie", &cookie),
)
.await
.expect("workspace task run get response");
assert_eq!(task_run_response.status(), StatusCode::OK);
let latest: WorkspaceTaskRunSummary = task_run_response
.json()
.expect("workspace task run get payload");
if matches!(
latest.status,
WorkspaceTaskRunStatus::Complete
| WorkspaceTaskRunStatus::Failed
| WorkspaceTaskRunStatus::Cancelled
| WorkspaceTaskRunStatus::TimedOut
) {
completed_task_run = Some(latest);
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
let completed_task_run = completed_task_run.expect("workspace task run should finish");
assert_eq!(completed_task_run.status, WorkspaceTaskRunStatus::Complete);
assert_eq!(completed_task_run.exit_code, Some(0));
assert!(completed_task_run.stdout_tail.contains("?? README.md"));
assert!(completed_task_run.completed_at_ms.is_some());
assert_eq!(completed_task_run.max_output_bytes, 64 * 1024);
let task_runs_response = app
.send(TestRequest::get("/api/workspace/task-runs").header("cookie", &cookie))
.await
.expect("workspace task runs list response");
assert_eq!(task_runs_response.status(), StatusCode::OK);
let task_runs: Vec<WorkspaceTaskRunSummary> = task_runs_response
.json()
.expect("workspace task runs list payload");
assert!(task_runs.iter().any(|run| run.run_id == task_run.run_id));
context
.workspace_task_runs
.write()
.expect("workspace task runs")
.clear();
let persisted_task_run_response = app
.send(
TestRequest::get(format!("/api/workspace/task-runs/{}", task_run.run_id))
.header("cookie", &cookie),
)
.await
.expect("persisted workspace task run get response");
assert_eq!(persisted_task_run_response.status(), StatusCode::OK);
let persisted_task_run: WorkspaceTaskRunSummary = persisted_task_run_response
.json()
.expect("persisted workspace task run payload");
assert_eq!(persisted_task_run.run_id, task_run.run_id);
assert_eq!(persisted_task_run.status, WorkspaceTaskRunStatus::Complete);
let persisted_task_runs_response = app
.send(TestRequest::get("/api/workspace/task-runs").header("cookie", &cookie))
.await
.expect("persisted workspace task runs list response");
assert_eq!(persisted_task_runs_response.status(), StatusCode::OK);
let persisted_task_runs: Vec<WorkspaceTaskRunSummary> = persisted_task_runs_response
.json()
.expect("persisted workspace task runs list payload");
assert!(
persisted_task_runs
.iter()
.any(|run| run.run_id == task_run.run_id)
);
let filtered_task_runs_response = app
.send(
TestRequest::get("/api/workspace/task-runs?error_code=workspace_task_failed_exit")
.header("cookie", &cookie),
)
.await
.expect("filtered workspace task runs list response");
assert_eq!(filtered_task_runs_response.status(), StatusCode::OK);
let filtered_task_runs: Vec<WorkspaceTaskRunSummary> = filtered_task_runs_response
.json()
.expect("filtered workspace task runs list payload");
assert!(filtered_task_runs.is_empty());
let cancel_completed_response = app
.send(
TestRequest::post(format!(
"/api/workspace/task-runs/{}/cancel",
task_run.run_id
))
.header("cookie", &cookie)
.header("origin", "http://test.local"),
)
.await
.expect("workspace task run cancel response");
assert_eq!(cancel_completed_response.status(), StatusCode::OK);
let cancel_completed: WorkspaceTaskRunSummary = cancel_completed_response
.json()
.expect("workspace task run cancel payload");
assert_eq!(cancel_completed.status, WorkspaceTaskRunStatus::Complete);
let cargo_check_start_response = app
.send(
TestRequest::post("/api/workspace/task-runs")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "task_id": "cargo_check" }))
.expect("workspace cargo check start request"),
)
.await
.expect("workspace cargo check start response");
assert_eq!(cargo_check_start_response.status(), StatusCode::OK);
let cargo_check: WorkspaceTaskRunSummary = cargo_check_start_response
.json()
.expect("workspace cargo check start payload");
assert_eq!(cargo_check.path, ".");
assert_eq!(cargo_check.status, WorkspaceTaskRunStatus::Running);
assert_eq!(cargo_check.command_label, "cargo check --workspace");
let mut completed_cargo_check = None;
for _ in 0..100 {
let cargo_check_response = app
.send(
TestRequest::get(format!("/api/workspace/task-runs/{}", cargo_check.run_id))
.header("cookie", &cookie),
)
.await
.expect("workspace cargo check get response");
assert_eq!(cargo_check_response.status(), StatusCode::OK);
let latest: WorkspaceTaskRunSummary = cargo_check_response
.json()
.expect("workspace cargo check get payload");
if matches!(
latest.status,
WorkspaceTaskRunStatus::Complete
| WorkspaceTaskRunStatus::Failed
| WorkspaceTaskRunStatus::Cancelled
| WorkspaceTaskRunStatus::TimedOut
) {
completed_cargo_check = Some(latest);
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
let completed_cargo_check =
completed_cargo_check.expect("workspace cargo check should finish");
assert_eq!(
completed_cargo_check.status,
WorkspaceTaskRunStatus::Complete
);
assert_eq!(completed_cargo_check.exit_code, Some(0));
assert_eq!(completed_cargo_check.path, ".");
let scoped_cargo_check_response = app
.send(
TestRequest::post("/api/workspace/task-runs")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "task_id": "cargo_check", "path": "docs" }))
.expect("workspace scoped cargo check request"),
)
.await
.expect("workspace scoped cargo check response");
assert_eq!(
scoped_cargo_check_response.status(),
StatusCode::BAD_REQUEST
);
let scoped_cargo_check_error: ApiErrorResponse = scoped_cargo_check_response
.json()
.expect("workspace scoped cargo check error");
assert_eq!(scoped_cargo_check_error.code, "invalid_request");
let git_add = std::process::Command::new("git")
.arg("add")
.arg("README.md")
.current_dir(&temp_dir)
.output()
.expect("run git add");
assert!(
git_add.status.success(),
"git add failed: {}",
String::from_utf8_lossy(&git_add.stderr)
);
std::fs::write(temp_dir.join("README.md"), "# Readme\n\nchanged").expect("edit readme");
let workspace_diff_response = app
.send(
TestRequest::post("/api/workspace/task")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "task_id": "git_diff", "path": "README.md" }))
.expect("workspace diff request"),
)
.await
.expect("workspace diff response");
assert_eq!(workspace_diff_response.status(), StatusCode::OK);
let workspace_diff: WorkspaceTaskRunResponse = workspace_diff_response
.json()
.expect("workspace diff payload");
assert_eq!(workspace_diff.path, "README.md");
assert_eq!(workspace_diff.command_label, "git diff --no-ext-diff");
assert_eq!(workspace_diff.exit_code, Some(0));
assert!(!workspace_diff.cancelled);
assert!(workspace_diff.stdout.contains("-# Readme"));
assert!(workspace_diff.stdout.contains("+changed"));
let unsafe_workspace_task_response = app
.send(
TestRequest::post("/api/workspace/task")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "task_id": "git_status", "path": ".." }))
.expect("unsafe workspace task request"),
)
.await
.expect("unsafe workspace task response");
assert_eq!(
unsafe_workspace_task_response.status(),
StatusCode::BAD_REQUEST
);
let unsafe_workspace_task_error: ApiErrorResponse = unsafe_workspace_task_response
.json()
.expect("unsafe workspace task error");
assert_eq!(unsafe_workspace_task_error.code, "invalid_request");
let unsafe_task_run_response = app
.send(
TestRequest::post("/api/workspace/task-runs")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "task_id": "git_status", "path": ".." }))
.expect("unsafe workspace task run request"),
)
.await
.expect("unsafe workspace task run response");
assert_eq!(unsafe_task_run_response.status(), StatusCode::BAD_REQUEST);
let unsafe_task_run_error: ApiErrorResponse = unsafe_task_run_response
.json()
.expect("unsafe workspace task run error");
assert_eq!(unsafe_task_run_error.code, "invalid_request");
let missing_task_run_response = app
.send(
TestRequest::get(format!("/api/workspace/task-runs/{}", Uuid::new_v4()))
.header("cookie", &cookie),
)
.await
.expect("missing workspace task run response");
assert_eq!(missing_task_run_response.status(), StatusCode::NOT_FOUND);
let missing_task_run_error: ApiErrorResponse = missing_task_run_response
.json()
.expect("missing workspace task run error");
assert_eq!(missing_task_run_error.code, "workspace_task_run_not_found");
let unsafe_source_root_response = app
.send(
TestRequest::post("/api/workspace/source-root")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "path": ".." }))
.expect("unsafe workspace source root request"),
)
.await
.expect("unsafe workspace source root response");
assert_eq!(
unsafe_source_root_response.status(),
StatusCode::BAD_REQUEST
);
let escape_response = app
.send(TestRequest::get("/api/workspace/files?path=..").header("cookie", &cookie))
.await
.expect("workspace escape response");
assert_eq!(escape_response.status(), StatusCode::BAD_REQUEST);
let missing_response = app
.send(TestRequest::get("/api/workspace/files?path=missing").header("cookie", &cookie))
.await
.expect("workspace missing response");
assert_eq!(missing_response.status(), StatusCode::NOT_FOUND);
let _ = std::fs::remove_dir_all(temp_dir);
}
#[tokio::test]
async fn conversation_routes_roundtrip_and_rehydrate_on_restart() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-http-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = AppConfig {
app_name: "Soma Studio".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
project_root: temp_dir.clone(),
data_dir: temp_dir.clone(),
derived_dir: temp_dir.join("derived"),
notebook_dir: temp_dir.join("notebook"),
user_assets_dir: temp_dir.join("assets"),
db_path: temp_dir.join("test.db"),
web_build_dir: temp_dir.join("web"),
web_shell_file: temp_dir.join("web").join("spa.html"),
};
let storage = StudioStorage::open(&config).await.expect("storage");
let context = AppContext::new(config.clone(), storage.clone());
let session = context.issue_session().await.expect("session");
let cookie = format!("soma_studio_session={}", session.id);
let app = TestApp::new(build_ingress(&context), ());
let unauthorized_response = app
.send(
TestRequest::get("/api/conversations")
.header("cookie", format!("soma_studio_session={}", Uuid::new_v4())),
)
.await
.expect("unauthorized response");
assert_eq!(unauthorized_response.status(), StatusCode::UNAUTHORIZED);
let create_response = app
.send(
TestRequest::post("/api/conversations")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({}))
.expect("conversation create request"),
)
.await
.expect("create response");
assert_eq!(create_response.status(), StatusCode::OK);
let conversation: ConversationSummary =
create_response.json().expect("conversation payload");
let chat_response = app
.send(
TestRequest::post("/api/chat/send")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"conversation_id": conversation.id,
"message": "restore this conversation after restart"
}))
.expect("chat request"),
)
.await
.expect("chat response");
assert_eq!(chat_response.status(), StatusCode::OK);
let chat_payload: ChatSendResponse = chat_response.json().expect("chat payload");
assert_eq!(chat_payload.conversation_id, conversation.id);
assert!(!chat_payload.accepted);
assert!(chat_payload.warning.is_some());
assert_eq!(chat_payload.retrieval_strategy, "none");
assert_eq!(chat_payload.retrieval_result_count, 0);
let list_response = app
.send(TestRequest::get("/api/conversations").header("cookie", &cookie))
.await
.expect("list response");
assert_eq!(list_response.status(), StatusCode::OK);
let listed: Vec<ConversationSummary> = list_response.json().expect("conversation list");
assert_eq!(listed.len(), 1);
let messages_response = app
.send(
TestRequest::get(format!("/api/conversations/{}/messages", conversation.id))
.header("cookie", &cookie),
)
.await
.expect("messages response");
assert_eq!(
messages_response.status(),
StatusCode::OK,
"{}",
messages_response.text().unwrap_or("<non-utf8>")
);
let messages: Vec<ConversationMessage> =
messages_response.json().expect("messages payload");
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[1].role, "assistant");
let restarted_context = AppContext::new(config, storage);
let restarted_app = TestApp::new(build_ingress(&restarted_context), ());
let restarted_init_response = restarted_app
.send(TestRequest::get("/api/app/init").header("cookie", &cookie))
.await
.expect("restarted init response");
assert_eq!(restarted_init_response.status(), StatusCode::OK);
let restarted_init: serde_json::Value = restarted_init_response
.json()
.expect("restarted init payload");
assert_eq!(
restarted_init
.get("authenticated")
.and_then(|value| value.as_bool()),
Some(true)
);
assert_eq!(
restarted_init
.get("session_id")
.and_then(|value| value.as_str()),
Some(session.id.as_str())
);
let restarted_list_response = restarted_app
.send(TestRequest::get("/api/conversations").header("cookie", &cookie))
.await
.expect("restarted list response");
assert_eq!(restarted_list_response.status(), StatusCode::OK);
let restarted_list: Vec<ConversationSummary> = restarted_list_response
.json()
.expect("restarted conversation list");
assert_eq!(restarted_list.len(), 1);
assert_eq!(restarted_list[0].id, conversation.id);
let delete_response = restarted_app
.send(
TestRequest::delete(format!("/api/conversations/{}", conversation.id))
.header("cookie", &cookie)
.header("origin", "http://test.local"),
)
.await
.expect("delete response");
assert_eq!(delete_response.status(), StatusCode::OK);
let delete_payload: ConversationDeleteResponse =
delete_response.json().expect("delete payload");
assert!(delete_payload.deleted);
}
#[tokio::test]
async fn provider_mutation_routes_reject_unsupported_providers_as_client_errors() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-provider-http-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = AppConfig {
app_name: "Soma Studio".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
project_root: temp_dir.clone(),
data_dir: temp_dir.clone(),
derived_dir: temp_dir.join("derived"),
notebook_dir: temp_dir.join("notebook"),
user_assets_dir: temp_dir.join("assets"),
db_path: temp_dir.join("test.db"),
web_build_dir: temp_dir.join("web"),
web_shell_file: temp_dir.join("web").join("spa.html"),
};
let storage = StudioStorage::open(&config).await.expect("storage");
let context = AppContext::new(config, storage);
let session = context.issue_session().await.expect("session");
let cookie = format!("soma_studio_session={}", session.id);
let app = TestApp::new(build_ingress(&context), ());
let selection_response = app
.send(
TestRequest::post("/api/providers/selection")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"provider": "custom",
"model_id": "model-a"
}))
.expect("provider selection request"),
)
.await
.expect("provider selection response");
assert_eq!(selection_response.status(), StatusCode::BAD_REQUEST);
let test_response = app
.send(
TestRequest::post("/api/providers/test")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"provider": "custom"
}))
.expect("provider test request"),
)
.await
.expect("provider test response");
assert_eq!(test_response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn provider_mutation_routes_normalize_provider_identifiers() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-provider-normalize-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = AppConfig {
app_name: "Soma Studio".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
project_root: temp_dir.clone(),
data_dir: temp_dir.clone(),
derived_dir: temp_dir.join("derived"),
notebook_dir: temp_dir.join("notebook"),
user_assets_dir: temp_dir.join("assets"),
db_path: temp_dir.join("test.db"),
web_build_dir: temp_dir.join("web"),
web_shell_file: temp_dir.join("web").join("spa.html"),
};
let storage = StudioStorage::open(&config).await.expect("storage");
let context = AppContext::new(config, storage);
let session = context.issue_session().await.expect("session");
let cookie = format!("soma_studio_session={}", session.id);
let app = TestApp::new(build_ingress(&context), ());
let selection_response = app
.send(
TestRequest::post("/api/providers/selection")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"provider": " Ollama ",
"model_id": " model-a "
}))
.expect("provider selection request"),
)
.await
.expect("provider selection response");
assert_eq!(selection_response.status(), StatusCode::OK);
let selection: ProviderSelectionResponse = selection_response
.json()
.expect("provider selection payload");
assert_eq!(selection.selected_provider.as_deref(), Some("ollama"));
assert_eq!(selection.selected_model_id.as_deref(), Some("model-a"));
let test_response = app
.send(
TestRequest::post("/api/providers/test")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"provider": " LMSTUDIO "
}))
.expect("provider test request"),
)
.await
.expect("provider test response");
assert_eq!(test_response.status(), StatusCode::OK);
let provider_test: ProviderTestResponse =
test_response.json().expect("provider test payload");
assert_eq!(provider_test.provider, "lmstudio");
assert_eq!(provider_test.endpoint, "http://127.0.0.1:1234");
}
#[tokio::test]
async fn notebook_routes_roundtrip_markdown_and_typst_notes() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-notebook-http-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = AppConfig {
app_name: "Soma Studio".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
project_root: temp_dir.clone(),
data_dir: temp_dir.clone(),
derived_dir: temp_dir.join("derived"),
notebook_dir: temp_dir.join("notebook"),
user_assets_dir: temp_dir.join("assets"),
db_path: temp_dir.join("test.db"),
web_build_dir: temp_dir.join("web"),
web_shell_file: temp_dir.join("web").join("spa.html"),
};
let storage = StudioStorage::open(&config).await.expect("storage");
let context = AppContext::new(config, storage);
let session = context.issue_session().await.expect("session");
let cookie = format!("soma_studio_session={}", session.id);
let app = TestApp::new(build_ingress(&context), ());
let create_typst_response = app
.send(
TestRequest::post("/api/notebook/note")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"path": "reports/weekly.typ",
"format": "typst"
}))
.expect("typst create request"),
)
.await
.expect("typst create response");
assert_eq!(create_typst_response.status(), StatusCode::OK);
let typst_note: NotebookNoteContent = create_typst_response.json().expect("typst note");
assert_eq!(typst_note.format, NotebookNoteFormat::Typst);
assert_eq!(typst_note.render_status, "not_rendered");
let write_response = app
.send(
TestRequest::put("/api/notebook/note")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"path": "reports/weekly.typ",
"content": "= Weekly Report\n\nTypst artifact planning"
}))
.expect("typst write request"),
)
.await
.expect("typst write response");
assert_eq!(write_response.status(), StatusCode::OK);
let list_response = app
.send(TestRequest::get("/api/notebook/tree").header("cookie", &cookie))
.await
.expect("tree response");
assert_eq!(list_response.status(), StatusCode::OK);
let listed: Vec<NotebookNoteSummary> = list_response.json().expect("tree payload");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].path, "reports/weekly.typ");
let read_response = app
.send(
TestRequest::get("/api/notebook/note?path=reports%2Fweekly.typ")
.header("cookie", &cookie),
)
.await
.expect("encoded note read response");
assert_eq!(read_response.status(), StatusCode::OK);
let read_note: NotebookNoteContent = read_response.json().expect("encoded note payload");
assert_eq!(read_note.path, "reports/weekly.typ");
assert_eq!(
read_note.content,
"= Weekly Report\n\nTypst artifact planning"
);
let search_response = app
.send(TestRequest::get("/api/notebook/search?query=artifact").header("cookie", &cookie))
.await
.expect("search response");
assert_eq!(search_response.status(), StatusCode::OK);
let results: Vec<NotebookSearchResult> = search_response.json().expect("search payload");
assert_eq!(results.len(), 1);
assert_eq!(results[0].format, NotebookNoteFormat::Typst);
let index_response = app
.send(
TestRequest::post("/api/notebook/index")
.header("cookie", &cookie)
.header("origin", "http://test.local"),
)
.await
.expect("index response");
assert_eq!(index_response.status(), StatusCode::OK);
let index: NotebookIndexResponse = index_response.json().expect("index payload");
assert_eq!(index.indexed, 1);
assert_eq!(index.items[0].path, "reports/weekly.typ");
assert_eq!(
index.items[0].index_path,
"notebook-index/reports/weekly.txt"
);
let index_status_response = app
.send(TestRequest::get("/api/notebook/index").header("cookie", &cookie))
.await
.expect("index status response");
assert_eq!(index_status_response.status(), StatusCode::OK);
let index_status: NotebookIndexResponse =
index_status_response.json().expect("index status payload");
assert_eq!(index_status.indexed, 1);
let chunks_response = app
.send(
TestRequest::post("/api/notebook/chunks")
.header("cookie", &cookie)
.header("origin", "http://test.local"),
)
.await
.expect("chunks response");
assert_eq!(chunks_response.status(), StatusCode::OK);
let chunks: NotebookChunkResponse = chunks_response.json().expect("chunks payload");
assert_eq!(chunks.chunked, 1);
assert_eq!(
chunks.items[0].chunk_path,
"notebook-chunks/reports/weekly.json"
);
let chunks_status_response = app
.send(TestRequest::get("/api/notebook/chunks").header("cookie", &cookie))
.await
.expect("chunks status response");
assert_eq!(chunks_status_response.status(), StatusCode::OK);
let chunks_status: NotebookChunkResponse = chunks_status_response
.json()
.expect("chunks status payload");
assert_eq!(chunks_status.chunked, 1);
let embeddings_response = app
.send(
TestRequest::post("/api/notebook/embeddings")
.header("cookie", &cookie)
.header("origin", "http://test.local"),
)
.await
.expect("embeddings response");
assert_eq!(embeddings_response.status(), StatusCode::BAD_REQUEST);
let embeddings_status_response = app
.send(TestRequest::get("/api/notebook/embeddings").header("cookie", &cookie))
.await
.expect("embeddings status response");
assert_eq!(embeddings_status_response.status(), StatusCode::BAD_REQUEST);
let retrieval_response = app
.send(
TestRequest::get("/api/notebook/retrieve?query=artifact").header("cookie", &cookie),
)
.await
.expect("retrieval response");
assert_eq!(retrieval_response.status(), StatusCode::OK);
let retrieval: NotebookRetrievalResponse =
retrieval_response.json().expect("retrieval payload");
assert_eq!(retrieval.query, "artifact");
assert_eq!(retrieval.results.len(), 1);
assert_eq!(retrieval.results[0].path, "reports/weekly.typ");
assert!(retrieval.results[0].score > 0);
let retrieval_chat_response = app
.send(
TestRequest::post("/api/chat/send")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"message": "artifact planning"
}))
.expect("retrieval chat request"),
)
.await
.expect("retrieval chat response");
assert_eq!(retrieval_chat_response.status(), StatusCode::OK);
let retrieval_chat: ChatSendResponse = retrieval_chat_response
.json()
.expect("retrieval chat payload");
assert_eq!(retrieval_chat.retrieval_strategy, "lexical");
assert_eq!(retrieval_chat.retrieval_result_count, 1);
assert_eq!(retrieval_chat.retrieval_sources.len(), 1);
assert_eq!(
retrieval_chat.retrieval_sources[0].path,
"reports/weekly.typ"
);
let adapters_response = app
.send(TestRequest::get("/api/notebook/adapters").header("cookie", &cookie))
.await
.expect("adapters response");
assert_eq!(adapters_response.status(), StatusCode::OK);
let adapters: Vec<NotebookAdapterStatus> =
adapters_response.json().expect("adapters payload");
let typst_adapter = adapters
.iter()
.find(|adapter| adapter.id == "typst")
.expect("typst adapter");
let render_response = app
.send(
TestRequest::post("/api/notebook/render")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"path": "reports/weekly.typ",
"target": "pdf"
}))
.expect("render request"),
)
.await
.expect("render response");
if typst_adapter.status == "available" {
assert_eq!(render_response.status(), StatusCode::OK);
let render: NotebookRenderResponse = render_response.json().expect("render payload");
assert_eq!(render.status, "complete");
assert_eq!(
render.artifact_url.as_deref(),
Some("/api/notebook/artifact?path=reports/weekly.pdf")
);
} else {
assert_eq!(render_response.status(), StatusCode::OK);
let render: NotebookRenderResponse = render_response.json().expect("render payload");
assert_eq!(render.status, typst_adapter.status);
}
let artifact = context
.config
.derived_dir
.join("notebook-artifacts")
.join("reports")
.join("weekly.pdf");
std::fs::create_dir_all(artifact.parent().expect("artifact parent")).expect("artifact dir");
std::fs::write(&artifact, b"%PDF-1.7").expect("artifact");
let artifact_response = app
.send(
TestRequest::get("/api/notebook/artifact?path=reports%2Fweekly.pdf")
.header("cookie", &cookie),
)
.await
.expect("artifact response");
assert_eq!(artifact_response.status(), StatusCode::OK);
assert_eq!(artifact_response.body(), b"%PDF-1.7");
let unauthenticated_artifact_response = app
.send(TestRequest::get(
"/api/notebook/artifact?path=reports/weekly.pdf",
))
.await
.expect("unauthenticated artifact response");
assert_eq!(
unauthenticated_artifact_response.status(),
StatusCode::UNAUTHORIZED
);
let missing_artifact_response = app
.send(
TestRequest::get("/api/notebook/artifact?path=reports/missing.pdf")
.header("cookie", &cookie),
)
.await
.expect("missing artifact response");
assert_eq!(missing_artifact_response.status(), StatusCode::NOT_FOUND);
let escape_response = app
.send(
TestRequest::post("/api/notebook/note")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"path": "../escape.md",
"format": "markdown"
}))
.expect("escape create request"),
)
.await
.expect("escape response");
assert_eq!(escape_response.status(), StatusCode::BAD_REQUEST);
let _ = std::fs::remove_dir_all(temp_dir);
}
#[tokio::test]
async fn ingest_routes_scan_source_roots_and_report_jobs() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-ingest-http-{}", Uuid::new_v4()));
let source_root = temp_dir.join("source");
std::fs::create_dir_all(&source_root).expect("source dir");
std::fs::write(source_root.join("alpha.md"), "# Alpha\n\nhello").expect("alpha");
std::fs::write(source_root.join("beta.txt"), "plain text").expect("beta");
let config = AppConfig {
app_name: "Soma Studio".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
project_root: temp_dir.clone(),
data_dir: temp_dir.clone(),
derived_dir: temp_dir.join("derived"),
notebook_dir: temp_dir.join("notebook"),
user_assets_dir: temp_dir.join("assets"),
db_path: temp_dir.join("test.db"),
web_build_dir: temp_dir.join("web"),
web_shell_file: temp_dir.join("web").join("spa.html"),
};
let storage = StudioStorage::open(&config).await.expect("storage");
let context = AppContext::new(config, storage);
let session = context.issue_session().await.expect("session");
let cookie = format!("soma_studio_session={}", session.id);
let app = TestApp::new(build_ingress(&context), ());
let created_root = app
.send(
TestRequest::post("/api/source-roots")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "path": source_root.to_string_lossy() }))
.expect("root create request"),
)
.await
.expect("root create response")
.json::<SourceRootSummary>()
.expect("root payload");
let rescan_response = app
.send(
TestRequest::post("/api/ingest/rescan")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "source_root_id": created_root.id }))
.expect("rescan request"),
)
.await
.expect("rescan response");
assert_eq!(rescan_response.status(), StatusCode::OK);
let job: IngestJobSummary = rescan_response.json().expect("rescan payload");
assert_eq!(job.status, "complete");
assert_eq!(job.file_count, 2);
let jobs_response = app
.send(TestRequest::get("/api/ingest/jobs").header("cookie", &cookie))
.await
.expect("jobs response");
assert_eq!(jobs_response.status(), StatusCode::OK);
let jobs: Vec<IngestJobSummary> = jobs_response.json().expect("jobs payload");
assert_eq!(jobs.len(), 1);
let status_response = app
.send(TestRequest::get("/api/ingest/status").header("cookie", &cookie))
.await
.expect("status response");
assert_eq!(status_response.status(), StatusCode::OK);
let status: IngestStatusResponse = status_response.json().expect("status payload");
assert!(!status.running);
assert_eq!(status.total_source_files, 2);
assert_eq!(status.indexed_text_files, 2);
assert!(
context
.config
.derived_dir
.join("source-root-text")
.join(created_root.id.to_string())
.join("alpha.txt")
.exists()
);
assert!(
context
.config
.derived_dir
.join("source-root-chunks")
.join(created_root.id.to_string())
.join("alpha.json")
.exists()
);
let retrieval_response = app
.send(TestRequest::get("/api/notebook/retrieve?query=hello").header("cookie", &cookie))
.await
.expect("source-root retrieval response");
assert_eq!(retrieval_response.status(), StatusCode::OK);
let retrieval: NotebookRetrievalResponse = retrieval_response
.json()
.expect("source-root retrieval payload");
assert_eq!(retrieval.strategy, "lexical");
assert_eq!(retrieval.results.len(), 1);
assert_eq!(
retrieval.results[0].path,
format!("source-root/{}/alpha.md", created_root.id)
);
assert_eq!(
retrieval.results[0].chunk_path,
format!("source-root-chunks/{}/alpha.json", created_root.id)
);
let _ = std::fs::remove_dir_all(temp_dir);
}
#[tokio::test]
async fn search_route_returns_notebook_and_source_root_results_without_provider() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-search-http-{}", Uuid::new_v4()));
let source_root = temp_dir.join("source");
std::fs::create_dir_all(&source_root).expect("source dir");
std::fs::write(source_root.join("alpha.md"), "# Alpha\n\nsource artifact")
.expect("source file");
let config = AppConfig {
app_name: "Soma Studio".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
project_root: temp_dir.clone(),
data_dir: temp_dir.clone(),
derived_dir: temp_dir.join("derived"),
notebook_dir: temp_dir.join("notebook"),
user_assets_dir: temp_dir.join("assets"),
db_path: temp_dir.join("test.db"),
web_build_dir: temp_dir.join("web"),
web_shell_file: temp_dir.join("web").join("spa.html"),
};
let storage = StudioStorage::open(&config).await.expect("storage");
let context = AppContext::new(config, storage);
let session = context.issue_session().await.expect("session");
let cookie = format!("soma_studio_session={}", session.id);
let app = TestApp::new(build_ingress(&context), ());
let note_response = app
.send(
TestRequest::post("/api/notebook/note")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"path": "notes/search.md",
"format": "markdown"
}))
.expect("note create request"),
)
.await
.expect("note create response");
assert_eq!(note_response.status(), StatusCode::OK);
let write_response = app
.send(
TestRequest::put("/api/notebook/note")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({
"path": "notes/search.md",
"content": "# Search\n\nnotebook artifact"
}))
.expect("note write request"),
)
.await
.expect("note write response");
assert_eq!(write_response.status(), StatusCode::OK);
let created_root = app
.send(
TestRequest::post("/api/source-roots")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "path": source_root.to_string_lossy() }))
.expect("root create request"),
)
.await
.expect("root create response")
.json::<SourceRootSummary>()
.expect("root payload");
let rescan_response = app
.send(
TestRequest::post("/api/ingest/rescan")
.header("cookie", &cookie)
.header("origin", "http://test.local")
.json(&json!({ "source_root_id": created_root.id }))
.expect("rescan request"),
)
.await
.expect("rescan response");
assert_eq!(rescan_response.status(), StatusCode::OK);
let missing_index_response = app
.send(TestRequest::get("/api/search/index/status").header("cookie", &cookie))
.await
.expect("missing search index status response");
assert_eq!(missing_index_response.status(), StatusCode::OK);
let missing_index: SearchIndexStatusResponse = missing_index_response
.json()
.expect("missing search index status payload");
assert!(!missing_index.ready);
assert!(!missing_index.exists);
let missing_search_response = app
.send(TestRequest::get("/api/search?q=artifact").header("cookie", &cookie))
.await
.expect("missing index search response");
assert_eq!(missing_search_response.status(), StatusCode::BAD_REQUEST);
let rebuild_response = app
.send(
TestRequest::post("/api/search/index/rebuild")
.header("cookie", &cookie)
.header("origin", "http://test.local"),
)
.await
.expect("search index rebuild response");
assert_eq!(rebuild_response.status(), StatusCode::OK);
let rebuild: SearchIndexRebuildResponse = rebuild_response
.json()
.expect("search index rebuild payload");
assert_eq!(rebuild.indexed_documents, 2);
assert!(rebuild.indexed_chunks >= 2);
assert!(rebuild.status.ready);
for endpoint in ["/api/search/index/sync", "/api/search/index/recover"] {
let maintenance_response = app
.send(
TestRequest::post(endpoint)
.header("cookie", &cookie)
.header("origin", "http://test.local"),
)
.await
.expect("search index maintenance response");
assert_eq!(maintenance_response.status(), StatusCode::OK);
let maintenance: SearchIndexRebuildResponse = maintenance_response
.json()
.expect("search index maintenance payload");
assert_eq!(maintenance.indexed_documents, 2);
assert!(maintenance.indexed_chunks >= 2);
assert!(maintenance.status.ready);
}
let ready_index_response = app
.send(TestRequest::get("/api/search/index/status").header("cookie", &cookie))
.await
.expect("ready search index status response");
assert_eq!(ready_index_response.status(), StatusCode::OK);
let ready_index: SearchIndexStatusResponse = ready_index_response
.json()
.expect("ready search index status payload");
assert!(ready_index.ready);
assert_eq!(ready_index.document_count, 2);
let search_response = app
.send(TestRequest::get("/api/search?q=artifact").header("cookie", &cookie))
.await
.expect("search response");
assert_eq!(search_response.status(), StatusCode::OK);
let search: SearchResponse = search_response.json().expect("search payload");
assert_eq!(search.query, "artifact");
assert_eq!(search.field, SearchFieldScope::All);
assert_eq!(search.sort, SearchSort::Relevance);
assert_eq!(search.limit, 25);
assert_eq!(search.offset, 0);
assert_eq!(search.total_results, 2);
assert_eq!(search.results.len(), 2);
assert!(
search
.results
.iter()
.any(|result| result.source_type == SearchSourceType::Notebook)
);
assert!(
search
.results
.iter()
.any(|result| result.source_type == SearchSourceType::SourceRoot)
);
assert!(
search
.results
.iter()
.all(|result| result.highlights == vec!["artifact".to_string()])
);
assert!(
search
.results
.iter()
.all(|result| { result.status == soma_studio_core::SearchDocumentStatus::Ready })
);
let paged_response = app
.send(
TestRequest::get("/api/search?q=artifact&limit=1&offset=1")
.header("cookie", &cookie),
)
.await
.expect("paged search response");
assert_eq!(paged_response.status(), StatusCode::OK);
let paged: SearchResponse = paged_response.json().expect("paged search payload");
assert_eq!(paged.field, SearchFieldScope::All);
assert_eq!(paged.limit, 1);
assert_eq!(paged.offset, 1);
assert_eq!(paged.total_results, 2);
assert_eq!(paged.previous_cursor.as_deref(), Some("offset:0"));
assert!(paged.next_cursor.is_none());
assert_eq!(paged.results.len(), 1);
let cursor_response = app
.send(
TestRequest::get("/api/search?q=artifact&limit=1&cursor=offset:1")
.header("cookie", &cookie),
)
.await
.expect("cursor search response");
assert_eq!(cursor_response.status(), StatusCode::OK);
let cursor_page: SearchResponse = cursor_response.json().expect("cursor search payload");
assert_eq!(cursor_page.offset, 1);
assert_eq!(cursor_page.previous_cursor.as_deref(), Some("offset:0"));
assert_eq!(cursor_page.results.len(), 1);
let filtered_response = app
.send(
TestRequest::get("/api/search?q=artifact&source_type=source_root")
.header("cookie", &cookie),
)
.await
.expect("filtered search response");
assert_eq!(filtered_response.status(), StatusCode::OK);
let filtered: SearchResponse = filtered_response.json().expect("filtered payload");
assert_eq!(filtered.results.len(), 1);
assert_eq!(
filtered.results[0].source_type,
SearchSourceType::SourceRoot
);
let title_field_response = app
.send(TestRequest::get("/api/search?q=artifact&field=title").header("cookie", &cookie))
.await
.expect("title field search response");
assert_eq!(title_field_response.status(), StatusCode::OK);
let title_field: SearchResponse = title_field_response.json().expect("title field payload");
assert_eq!(title_field.field, SearchFieldScope::Title);
let sorted_response = app
.send(
TestRequest::get("/api/search?q=artifact&sort=updated_at")
.header("cookie", &cookie),
)
.await
.expect("sorted search response");
assert_eq!(sorted_response.status(), StatusCode::OK);
let sorted: SearchResponse = sorted_response.json().expect("sorted payload");
assert_eq!(sorted.sort, SearchSort::UpdatedAt);
assert!(sorted.results.iter().all(|result| result.updated_at_ms > 0));
let invalid_field_response = app
.send(
TestRequest::get("/api/search?q=artifact&field=unknown").header("cookie", &cookie),
)
.await
.expect("invalid field search response");
assert_eq!(invalid_field_response.status(), StatusCode::BAD_REQUEST);
let invalid_sort_response = app
.send(TestRequest::get("/api/search?q=artifact&sort=size").header("cookie", &cookie))
.await
.expect("invalid sort search response");
assert_eq!(invalid_sort_response.status(), StatusCode::BAD_REQUEST);
let invalid_cursor_response = app
.send(TestRequest::get("/api/search?q=artifact&cursor=bad").header("cookie", &cookie))
.await
.expect("invalid cursor search response");
assert_eq!(invalid_cursor_response.status(), StatusCode::BAD_REQUEST);
let mixed_cursor_offset_response = app
.send(
TestRequest::get("/api/search?q=artifact&cursor=offset:1&offset=1")
.header("cookie", &cookie),
)
.await
.expect("mixed cursor offset search response");
assert_eq!(
mixed_cursor_offset_response.status(),
StatusCode::BAD_REQUEST
);
let encoded_open_path = filtered.results[0].path.replace('/', "%2F");
let open_action_response = app
.send(
TestRequest::get(format!(
"/api/search/open-action?action=copy_path&path={}",
encoded_open_path
))
.header("cookie", &cookie),
)
.await
.expect("source open action response");
assert_eq!(open_action_response.status(), StatusCode::OK);
let open_action: SearchOpenActionResponse = open_action_response
.json()
.expect("source open action payload");
assert!(open_action.allowed);
assert_eq!(open_action.action, "copy_path");
assert!(open_action.canonical_path.ends_with("alpha.md"));
let invalid_filter_response = app
.send(
TestRequest::get("/api/search?q=artifact&source_type=external")
.header("cookie", &cookie),
)
.await
.expect("invalid filtered search response");
assert_eq!(invalid_filter_response.status(), StatusCode::BAD_REQUEST);
let _ = std::fs::remove_dir_all(temp_dir);
}
}