diff --git a/.turbo b/.turbo new file mode 120000 index 0000000..0b7d9ca --- /dev/null +++ b/.turbo @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/.turbo \ No newline at end of file diff --git a/dist b/dist new file mode 120000 index 0000000..f02d77f --- /dev/null +++ b/dist @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/dist \ No newline at end of file diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..501480b --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/node_modules \ No newline at end of file diff --git a/server/packages/sandbox-agent/src/lib.rs b/server/packages/sandbox-agent/src/lib.rs index 8c11343..c95dd07 100644 --- a/server/packages/sandbox-agent/src/lib.rs +++ b/server/packages/sandbox-agent/src/lib.rs @@ -3,6 +3,7 @@ mod agent_server_logs; pub mod credentials; pub mod opencode_compat; +mod project_manager; pub mod router; pub mod server_logs; pub mod telemetry; diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs index 55b7050..da0b7a2 100644 --- a/server/packages/sandbox-agent/src/opencode_compat.rs +++ b/server/packages/sandbox-agent/src/opencode_compat.rs @@ -24,6 +24,7 @@ use tokio::time::interval; use utoipa::{IntoParams, OpenApi, ToSchema}; use crate::router::{AppState, CreateSessionRequest, PermissionReply}; +use crate::project_manager::{ProjectCommands, ProjectIcon, ProjectInfo, ProjectUpdate, WorktreeInfo}; use sandbox_agent_error::SandboxError; use sandbox_agent_agent_management::agents::AgentId; use sandbox_agent_universal_agent_schema::{ @@ -303,7 +304,7 @@ impl OpenCodeState { .unwrap_or_else(|| format!("{}/.local/state/opencode", self.home_dir())) } - async fn ensure_session(&self, session_id: &str, directory: String) -> Value { + async fn ensure_session(&self, session_id: &str, directory: String, project_id: Option) -> Value { let mut sessions = self.sessions.lock().await; if let Some(existing) = sessions.get(session_id) { return existing.to_value(); @@ -313,7 +314,7 @@ impl OpenCodeState { let record = OpenCodeSessionRecord { id: session_id.to_string(), slug: format!("session-{}", session_id), - project_id: self.default_project_id.clone(), + project_id: project_id.unwrap_or_else(|| self.default_project_id.clone()), directory, parent_id: None, title: format!("Session {}", session_id), @@ -544,6 +545,27 @@ struct SessionSummarizeRequest { auto: Option, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct ProjectUpdateInput { + name: Option, + icon: Option, + commands: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +struct ProjectIconInput { + url: Option, + #[serde(rename = "override")] + override_name: Option, + color: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +struct ProjectCommandsInput { + start: Option, +} + #[derive(Debug, Serialize, Deserialize, ToSchema)] struct PermissionReplyRequest { response: Option, @@ -561,6 +583,24 @@ struct QuestionReplyBody { answers: Option>>, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct WorktreeCreateInput { + name: Option, + #[serde(rename = "startCommand")] + start_command: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +struct WorktreeRemoveInput { + directory: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +struct WorktreeResetInput { + directory: String, +} + #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] struct PtyCreateRequest { @@ -783,6 +823,67 @@ fn bool_ok(value: bool) -> (StatusCode, Json) { (StatusCode::OK, Json(json!(value))) } +fn project_info_to_value(info: &ProjectInfo) -> Value { + let mut map = serde_json::Map::new(); + map.insert("id".to_string(), json!(info.id)); + map.insert("worktree".to_string(), json!(info.worktree)); + map.insert("vcs".to_string(), json!("git")); + map.insert("name".to_string(), json!(info.name)); + map.insert( + "time".to_string(), + json!({ + "created": info.created_at, + "updated": info.updated_at, + }), + ); + map.insert("sandboxes".to_string(), json!([])); + map.insert("directory".to_string(), json!(info.directory)); + map.insert("branch".to_string(), json!(info.branch)); + if let Some(icon) = &info.icon { + let mut icon_map = serde_json::Map::new(); + if let Some(url) = &icon.url { + icon_map.insert("url".to_string(), json!(url)); + } + if let Some(override_name) = &icon.override_name { + icon_map.insert("override".to_string(), json!(override_name)); + } + if let Some(color) = &icon.color { + icon_map.insert("color".to_string(), json!(color)); + } + map.insert("icon".to_string(), Value::Object(icon_map)); + } + if let Some(commands) = &info.commands { + let mut commands_map = serde_json::Map::new(); + if let Some(start) = &commands.start { + commands_map.insert("start".to_string(), json!(start)); + } + map.insert("commands".to_string(), Value::Object(commands_map)); + } + Value::Object(map) +} + +fn project_update_from_input(input: ProjectUpdateInput) -> ProjectUpdate { + ProjectUpdate { + name: input.name, + icon: input.icon.map(|icon| ProjectIcon { + url: icon.url, + override_name: icon.override_name, + color: icon.color, + }), + commands: input.commands.map(|commands| ProjectCommands { + start: commands.start, + }), + } +} + +fn worktree_info_to_value(worktree: &WorktreeInfo) -> Value { + json!({ + "name": worktree.name, + "branch": worktree.branch, + "directory": worktree.directory, + }) +} + fn build_user_message( session_id: &str, message_id: &str, @@ -2528,13 +2629,23 @@ async fn oc_path(State(state): State>, headers: HeaderMap) responses((status = 200)), tag = "opencode" )] -async fn oc_vcs(State(state): State>) -> impl IntoResponse { - ( - StatusCode::OK, - Json(json!({ - "branch": state.opencode.branch_name(), - })), - ) +async fn oc_vcs( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let manager = state.inner.session_manager().project_manager(); + match manager.resolve_project(&directory).await { + Ok(project) => ( + StatusCode::OK, + Json(json!({ + "branch": project.branch, + })), + ) + .into_response(), + Err(err) => sandbox_error_response(err).into_response(), + } } #[utoipa::path( @@ -2543,19 +2654,20 @@ async fn oc_vcs(State(state): State>) -> impl IntoResponse responses((status = 200)), tag = "opencode" )] -async fn oc_project_list(State(state): State>, headers: HeaderMap) -> impl IntoResponse { - let directory = state.opencode.directory_for(&headers, None); - let worktree = state.opencode.worktree_for(&directory); - let now = state.opencode.now_ms(); - let project = json!({ - "id": state.opencode.default_project_id.clone(), - "worktree": worktree, - "vcs": "git", - "name": "sandbox-agent", - "time": {"created": now, "updated": now}, - "sandboxes": [], - }); - (StatusCode::OK, Json(json!([project]))) +async fn oc_project_list( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let manager = state.inner.session_manager().project_manager(); + match manager.list_projects(&directory).await { + Ok(projects) => { + let values: Vec = projects.iter().map(project_info_to_value).collect(); + (StatusCode::OK, Json(json!(values))).into_response() + } + Err(err) => sandbox_error_response(err).into_response(), + } } #[utoipa::path( @@ -2564,28 +2676,24 @@ async fn oc_project_list(State(state): State>, headers: He responses((status = 200)), tag = "opencode" )] -async fn oc_project_current(State(state): State>, headers: HeaderMap) -> impl IntoResponse { - let directory = state.opencode.directory_for(&headers, None); - let worktree = state.opencode.worktree_for(&directory); - let now = state.opencode.now_ms(); - ( - StatusCode::OK, - Json(json!({ - "id": state.opencode.default_project_id.clone(), - "worktree": worktree, - "vcs": "git", - "name": "sandbox-agent", - "time": {"created": now, "updated": now}, - "sandboxes": [], - })), - ) +async fn oc_project_current( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let manager = state.inner.session_manager().project_manager(); + match manager.resolve_project(&directory).await { + Ok(project) => (StatusCode::OK, Json(project_info_to_value(&project))).into_response(), + Err(err) => sandbox_error_response(err).into_response(), + } } #[utoipa::path( patch, path = "/project/{projectID}", params(("projectID" = String, Path, description = "Project ID")), - request_body = String, + request_body = ProjectUpdateInput, responses((status = 200)), tag = "opencode" )] @@ -2593,8 +2701,27 @@ async fn oc_project_update( State(state): State>, Path(_project_id): Path, headers: HeaderMap, + Query(query): Query, + body: Option>, ) -> impl IntoResponse { - oc_project_current(State(state), headers).await + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let manager = state.inner.session_manager().project_manager(); + let project_id = _project_id.clone(); + if body.is_none() { + return bad_request("project update body required").into_response(); + } + let update = project_update_from_input(body.unwrap().0); + match manager.update_project(&project_id, update).await { + Ok(project) => { + let value = project_info_to_value(&project); + state + .opencode + .emit_event(json!({"type": "project.updated", "properties": value})); + (StatusCode::OK, Json(value)).into_response() + } + Err(SandboxError::SessionNotFound { .. }) => not_found("Project not found").into_response(), + Err(err) => sandbox_error_response(err).into_response(), + } } #[utoipa::path( @@ -2616,6 +2743,15 @@ async fn oc_session_create( permission: None, }); let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let project_id = state + .inner + .session_manager() + .project_manager() + .resolve_project(&directory) + .await + .ok() + .map(|project| project.id) + .unwrap_or_else(|| state.opencode.default_project_id.clone()); let now = state.opencode.now_ms(); let id = next_id("ses_", &SESSION_COUNTER); let slug = format!("session-{}", id); @@ -2623,7 +2759,7 @@ async fn oc_session_create( let record = OpenCodeSessionRecord { id: id.clone(), slug, - project_id: state.opencode.default_project_id.clone(), + project_id, directory, parent_id: body.parent_id, title, @@ -2793,6 +2929,15 @@ async fn oc_session_fork( Query(query): Query, ) -> impl IntoResponse { let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let project_id = state + .inner + .session_manager() + .project_manager() + .resolve_project(&directory) + .await + .ok() + .map(|project| project.id) + .unwrap_or_else(|| state.opencode.default_project_id.clone()); let now = state.opencode.now_ms(); let id = next_id("ses_", &SESSION_COUNTER); let slug = format!("session-{}", id); @@ -2800,7 +2945,7 @@ async fn oc_session_fork( let record = OpenCodeSessionRecord { id: id.clone(), slug, - project_id: state.opencode.default_project_id.clone(), + project_id, directory, parent_id: Some(session_id), title, @@ -2889,9 +3034,17 @@ async fn oc_session_message_create( tracing::info!(target = "sandbox_agent::opencode", ?body, "opencode prompt body"); } let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let project_id = state + .inner + .session_manager() + .project_manager() + .resolve_project(&directory) + .await + .ok() + .map(|project| project.id); let _ = state .opencode - .ensure_session(&session_id, directory.clone()) + .ensure_session(&session_id, directory.clone(), project_id) .await; let worktree = state.opencode.worktree_for(&directory); let agent_mode = normalize_agent_mode(body.agent.clone()); @@ -3962,30 +4115,39 @@ async fn oc_resource_list() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_worktree_list(State(state): State>, headers: HeaderMap) -> impl IntoResponse { - let directory = state.opencode.directory_for(&headers, None); - let worktree = state.opencode.worktree_for(&directory); - (StatusCode::OK, Json(json!([worktree]))) +async fn oc_worktree_list( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let manager = state.inner.session_manager().project_manager(); + match manager.list_worktrees(&directory).await { + Ok(worktrees) => (StatusCode::OK, Json(json!(worktrees))).into_response(), + Err(err) => sandbox_error_response(err).into_response(), + } } #[utoipa::path( post, path = "/experimental/worktree", - request_body = String, + request_body = WorktreeCreateInput, responses((status = 200)), tag = "opencode" )] -async fn oc_worktree_create(State(state): State>, headers: HeaderMap) -> impl IntoResponse { - let directory = state.opencode.directory_for(&headers, None); - let worktree = state.opencode.worktree_for(&directory); - ( - StatusCode::OK, - Json(json!({ - "name": "worktree", - "branch": state.opencode.branch_name(), - "directory": worktree, - })), - ) +async fn oc_worktree_create( + State(state): State>, + headers: HeaderMap, + Query(query): Query, + body: Option>, +) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let manager = state.inner.session_manager().project_manager(); + let name = body.as_ref().and_then(|value| value.name.clone()); + match manager.create_worktree(&directory, name).await { + Ok(worktree) => (StatusCode::OK, Json(worktree_info_to_value(&worktree))).into_response(), + Err(err) => sandbox_error_response(err).into_response(), + } } #[utoipa::path( @@ -3994,8 +4156,18 @@ async fn oc_worktree_create(State(state): State>, headers: responses((status = 200)), tag = "opencode" )] -async fn oc_worktree_delete() -> impl IntoResponse { - bool_ok(true) +async fn oc_worktree_delete( + State(state): State>, + body: Option>, +) -> impl IntoResponse { + let Some(body) = body else { + return bad_request("worktree directory required").into_response(); + }; + let manager = state.inner.session_manager().project_manager(); + match manager.remove_worktree(&body.directory).await { + Ok(()) => bool_ok(true).into_response(), + Err(err) => sandbox_error_response(err).into_response(), + } } #[utoipa::path( @@ -4004,8 +4176,18 @@ async fn oc_worktree_delete() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_worktree_reset() -> impl IntoResponse { - bool_ok(true) +async fn oc_worktree_reset( + State(state): State>, + body: Option>, +) -> impl IntoResponse { + let Some(body) = body else { + return bad_request("worktree directory required").into_response(); + }; + let manager = state.inner.session_manager().project_manager(); + match manager.reset_worktree(&body.directory).await { + Ok(()) => bool_ok(true).into_response(), + Err(err) => sandbox_error_response(err).into_response(), + } } #[utoipa::path( @@ -4264,9 +4446,15 @@ async fn oc_tui_select_session() -> impl IntoResponse { SessionCommandRequest, SessionShellRequest, SessionSummarizeRequest, + ProjectUpdateInput, + ProjectIconInput, + ProjectCommandsInput, PermissionReplyRequest, PermissionGlobalReplyRequest, QuestionReplyBody, + WorktreeCreateInput, + WorktreeRemoveInput, + WorktreeResetInput, PtyCreateRequest )), tags((name = "opencode", description = "OpenCode compatibility API")) diff --git a/server/packages/sandbox-agent/src/project_manager.rs b/server/packages/sandbox-agent/src/project_manager.rs new file mode 100644 index 0000000..353516d --- /dev/null +++ b/server/packages/sandbox-agent/src/project_manager.rs @@ -0,0 +1,435 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +use sandbox_agent_error::SandboxError; + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub(crate) struct ProjectIcon { + pub(crate) url: Option, + pub(crate) override_name: Option, + pub(crate) color: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub(crate) struct ProjectCommands { + pub(crate) start: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct ProjectInfo { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) worktree: String, + pub(crate) directory: String, + pub(crate) branch: String, + pub(crate) created_at: i64, + pub(crate) updated_at: i64, + pub(crate) icon: Option, + pub(crate) commands: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct WorktreeInfo { + pub(crate) name: String, + pub(crate) branch: String, + pub(crate) directory: String, +} + +#[derive(Clone, Debug, Default)] +struct ProjectState { + projects_by_id: HashMap, + project_id_by_root: HashMap, +} + +#[derive(Clone, Debug)] +struct ProjectRecord { + id: String, + root: String, + name: String, + created_at: i64, + updated_at: i64, + icon: Option, + commands: Option, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct ProjectUpdate { + pub(crate) name: Option, + pub(crate) icon: Option, + pub(crate) commands: Option, +} + +pub(crate) struct ProjectManager { + state: Mutex, + next_project_id: AtomicU64, + next_worktree_id: AtomicU64, +} + +impl ProjectManager { + pub(crate) fn new() -> Self { + Self { + state: Mutex::new(ProjectState::default()), + next_project_id: AtomicU64::new(1), + next_worktree_id: AtomicU64::new(1), + } + } + + pub(crate) async fn resolve_project(&self, directory: &str) -> Result { + let repo_root = resolve_repo_root(directory).await?; + let repo_root = canonicalize_path(&repo_root).await.unwrap_or(repo_root); + let repo_root_str = repo_root.to_string_lossy().to_string(); + let now = now_ms(); + + let mut state = self.state.lock().await; + let project_id = if let Some(existing_id) = state.project_id_by_root.get(&repo_root_str) { + existing_id.clone() + } else { + let id = format!("proj_{}", self.next_project_id.fetch_add(1, Ordering::Relaxed)); + state + .project_id_by_root + .insert(repo_root_str.clone(), id.clone()); + let name = repo_root + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("project") + .to_string(); + state.projects_by_id.insert( + id.clone(), + ProjectRecord { + id: id.clone(), + root: repo_root_str.clone(), + name, + created_at: now, + updated_at: now, + icon: None, + commands: None, + }, + ); + id + }; + + let record = state + .projects_by_id + .get_mut(&project_id) + .expect("project record missing"); + record.updated_at = now; + let branch = current_branch(directory).await.unwrap_or_else(|| "main".to_string()); + + Ok(ProjectInfo { + id: record.id.clone(), + name: record.name.clone(), + worktree: record.root.clone(), + directory: directory.to_string(), + branch, + created_at: record.created_at, + updated_at: record.updated_at, + icon: record.icon.clone(), + commands: record.commands.clone(), + }) + } + + pub(crate) async fn list_projects(&self, directory: &str) -> Result, SandboxError> { + let _ = self.resolve_project(directory).await?; + let state = self.state.lock().await; + let mut projects = Vec::new(); + for record in state.projects_by_id.values() { + let branch = current_branch(&record.root) + .await + .unwrap_or_else(|| "main".to_string()); + projects.push(ProjectInfo { + id: record.id.clone(), + name: record.name.clone(), + worktree: record.root.clone(), + directory: record.root.clone(), + branch, + created_at: record.created_at, + updated_at: record.updated_at, + icon: record.icon.clone(), + commands: record.commands.clone(), + }); + } + Ok(projects) + } + + pub(crate) async fn update_project( + &self, + project_id: &str, + update: ProjectUpdate, + ) -> Result { + let now = now_ms(); + let mut state = self.state.lock().await; + let record = state + .projects_by_id + .get_mut(project_id) + .ok_or_else(|| SandboxError::SessionNotFound { + session_id: project_id.to_string(), + })?; + + if let Some(name) = update.name { + record.name = name; + } + if let Some(icon) = update.icon { + record.icon = Some(icon); + } + if let Some(commands) = update.commands { + record.commands = Some(commands); + } + record.updated_at = now; + + let branch = current_branch(&record.root) + .await + .unwrap_or_else(|| "main".to_string()); + + Ok(ProjectInfo { + id: record.id.clone(), + name: record.name.clone(), + worktree: record.root.clone(), + directory: record.root.clone(), + branch, + created_at: record.created_at, + updated_at: record.updated_at, + icon: record.icon.clone(), + commands: record.commands.clone(), + }) + } + + pub(crate) async fn list_worktrees(&self, directory: &str) -> Result, SandboxError> { + let repo_root = resolve_repo_root(directory).await?; + list_git_worktrees(&repo_root).await + } + + pub(crate) async fn create_worktree( + &self, + directory: &str, + name: Option, + ) -> Result { + let repo_root = resolve_repo_root(directory).await?; + let repo_root = canonicalize_path(&repo_root).await.unwrap_or(repo_root); + let default_branch = default_branch(&repo_root).await.unwrap_or_else(|| "main".to_string()); + let raw_name = name.unwrap_or_else(|| { + let id = self.next_worktree_id.fetch_add(1, Ordering::Relaxed); + format!("worktree-{id}") + }); + let sanitized_name = sanitize_name(&raw_name); + if sanitized_name.is_empty() { + return Err(SandboxError::InvalidRequest { + message: "worktree name is required".to_string(), + }); + } + + let base_dir = repo_root.join(".opencode").join("worktrees"); + let worktree_dir = base_dir.join(&sanitized_name); + if worktree_dir.exists() { + return Err(SandboxError::InvalidRequest { + message: format!("worktree directory already exists: {}", worktree_dir.display()), + }); + } + tokio::fs::create_dir_all(&base_dir) + .await + .map_err(|err| SandboxError::InvalidRequest { + message: format!("failed to create worktree directory: {err}"), + })?; + + let branch = sanitized_name.clone(); + let args = vec![ + "worktree".to_string(), + "add".to_string(), + "-b".to_string(), + branch.clone(), + worktree_dir.to_string_lossy().to_string(), + default_branch, + ]; + let _ = run_git_command(&repo_root, &args).await?; + + Ok(WorktreeInfo { + name: sanitized_name, + branch, + directory: worktree_dir.to_string_lossy().to_string(), + }) + } + + pub(crate) async fn remove_worktree( + &self, + target_directory: &str, + ) -> Result<(), SandboxError> { + let target_path = PathBuf::from(target_directory); + if !target_path.exists() { + return Err(SandboxError::InvalidRequest { + message: format!("worktree directory does not exist: {}", target_directory), + }); + } + let branch = current_branch(&target_path) + .await + .unwrap_or_else(|| "".to_string()); + let repo_root = resolve_repo_root(&target_directory).await?; + let args = vec![ + "worktree".to_string(), + "remove".to_string(), + "--force".to_string(), + target_directory.to_string(), + ]; + let _ = run_git_command(&repo_root, &args).await?; + + let default_branch = default_branch(&repo_root).await.unwrap_or_else(|| "main".to_string()); + if !branch.is_empty() && branch != "HEAD" && branch != default_branch { + let delete_args = vec![ + "branch".to_string(), + "-D".to_string(), + branch, + ]; + let _ = run_git_command(&repo_root, &delete_args).await; + } + + Ok(()) + } + + pub(crate) async fn reset_worktree( + &self, + target_directory: &str, + ) -> Result<(), SandboxError> { + let repo_root = resolve_repo_root(&target_directory).await?; + let default_branch = default_branch(&repo_root).await.unwrap_or_else(|| "main".to_string()); + let args = vec![ + "reset".to_string(), + "--hard".to_string(), + default_branch, + ]; + let _ = run_git_command(Path::new(target_directory), &args).await?; + Ok(()) + } +} + +fn sanitize_name(name: &str) -> String { + let mut sanitized = String::new(); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + sanitized.push(ch); + } else if ch.is_ascii_whitespace() || ch == '/' { + sanitized.push('-'); + } + } + while sanitized.contains("--") { + sanitized = sanitized.replace("--", "-"); + } + sanitized.trim_matches('-').to_string() +} + +async fn resolve_repo_root(directory: &str) -> Result { + let output = run_git_command(Path::new(directory), &[ + "rev-parse".to_string(), + "--show-toplevel".to_string(), + ]) + .await?; + let trimmed = output.trim(); + if trimmed.is_empty() { + return Err(SandboxError::InvalidRequest { + message: "unable to resolve git worktree".to_string(), + }); + } + Ok(PathBuf::from(trimmed)) +} + +async fn list_git_worktrees(repo_root: &Path) -> Result, SandboxError> { + let output = run_git_command( + repo_root, + &[ + "worktree".to_string(), + "list".to_string(), + "--porcelain".to_string(), + ], + ) + .await?; + let mut worktrees = Vec::new(); + for line in output.lines() { + if let Some(path) = line.strip_prefix("worktree ") { + worktrees.push(path.trim().to_string()); + } + } + Ok(worktrees) +} + +async fn current_branch(directory: impl AsRef) -> Option { + let output = run_git_command( + directory.as_ref(), + &[ + "rev-parse".to_string(), + "--abbrev-ref".to_string(), + "HEAD".to_string(), + ], + ) + .await + .ok()?; + let trimmed = output.trim(); + if trimmed.is_empty() || trimmed == "HEAD" { + None + } else { + Some(trimmed.to_string()) + } +} + +async fn default_branch(repo_root: &Path) -> Option { + let output = run_git_command( + repo_root, + &[ + "symbolic-ref".to_string(), + "--short".to_string(), + "refs/remotes/origin/HEAD".to_string(), + ], + ) + .await + .ok(); + if let Some(output) = output { + let trimmed = output.trim(); + if let Some((_, branch)) = trimmed.split_once('/') { + return Some(branch.to_string()); + } + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + current_branch(repo_root).await +} + +async fn run_git_command(directory: &Path, args: &[String]) -> Result { + let directory = directory.to_path_buf(); + let args = args.to_vec(); + let output = tokio::task::spawn_blocking(move || Command::new("git").args(&args).current_dir(directory).output()) + .await + .map_err(|err| SandboxError::InvalidRequest { + message: format!("git command failed: {err}"), + }) + .and_then(|result| result.map_err(|err| SandboxError::InvalidRequest { + message: format!("git command failed: {err}"), + }))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(SandboxError::InvalidRequest { + message: if stderr.is_empty() { + "git command failed".to_string() + } else { + stderr + }, + }); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +async fn canonicalize_path(path: &Path) -> Option { + tokio::task::spawn_blocking(move || std::fs::canonicalize(path)) + .await + .ok()? + .ok() +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 3ca437a..f3064b3 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -40,6 +40,7 @@ use utoipa::{Modify, OpenApi, ToSchema}; use crate::agent_server_logs::AgentServerLogs; use crate::opencode_compat::{build_opencode_router, OpenCodeAppState}; +use crate::project_manager::ProjectManager; use crate::ui; use sandbox_agent_agent_management::agents::{ AgentError as ManagerError, AgentId, AgentManager, InstallOptions, SpawnOptions, StreamingSpawn, @@ -818,6 +819,7 @@ pub(crate) struct SessionManager { sessions: Mutex>, server_manager: Arc, http_client: Client, + project_manager: Arc, } /// Shared Codex app-server process that handles multiple sessions via JSON-RPC. @@ -1538,9 +1540,14 @@ impl SessionManager { sessions: Mutex::new(Vec::new()), server_manager, http_client: Client::new(), + project_manager: Arc::new(ProjectManager::new()), } } + pub(crate) fn project_manager(&self) -> Arc { + self.project_manager.clone() + } + fn session_ref<'a>(sessions: &'a [SessionState], session_id: &str) -> Option<&'a SessionState> { sessions .iter() diff --git a/server/packages/sandbox-agent/tests/opencode_project_worktree.rs b/server/packages/sandbox-agent/tests/opencode_project_worktree.rs new file mode 100644 index 0000000..9d21f08 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode_project_worktree.rs @@ -0,0 +1,159 @@ +include!("./common/http.rs"); + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn run_git(repo: &Path, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(repo) + .status() + .expect("run git command"); + assert!(status.success(), "git command failed: {:?}", args); +} + +fn init_repo() -> tempfile::TempDir { + let repo = tempfile::tempdir().expect("create repo dir"); + run_git(repo.path(), &["init", "-b", "main"]); + fs::write(repo.path().join("README.md"), "hello\n").expect("write file"); + run_git(repo.path(), &["add", "."]); + let status = Command::new("git") + .args([ + "-c", + "user.name=Sandbox", + "-c", + "user.email=sandbox@example.com", + "commit", + "-m", + "init", + ]) + .current_dir(repo.path()) + .status() + .expect("git commit"); + assert!(status.success(), "git commit failed"); + repo +} + +fn opencode_request( + method: Method, + path: &str, + directory: &Path, + body: Option, +) -> Request { + let mut builder = Request::builder() + .method(method) + .uri(path) + .header("x-opencode-directory", directory.to_string_lossy().to_string()); + let body = if let Some(body) = body { + builder = builder.header("content-type", "application/json"); + Body::from(body.to_string()) + } else { + Body::empty() + }; + builder.body(body).expect("request") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn opencode_project_metadata() { + let repo = init_repo(); + let app = TestApp::new(); + + let request = opencode_request(Method::GET, "/opencode/project/current", repo.path(), None); + let (status, _headers, payload) = send_json_request(&app.app, request).await; + assert_eq!(status, StatusCode::OK, "project current status"); + let project_id = payload.get("id").and_then(Value::as_str).unwrap_or(""); + assert!(!project_id.is_empty(), "project id missing"); + assert_eq!( + payload.get("vcs").and_then(Value::as_str), + Some("git") + ); + assert_eq!( + payload.get("worktree").and_then(Value::as_str), + Some(repo.path().to_string_lossy().as_ref()) + ); + assert_eq!( + payload.get("directory").and_then(Value::as_str), + Some(repo.path().to_string_lossy().as_ref()) + ); + assert_eq!( + payload.get("branch").and_then(Value::as_str), + Some("main") + ); + let repo_name = repo + .path() + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(""); + assert_eq!(payload.get("name").and_then(Value::as_str), Some(repo_name)); + + let request = opencode_request(Method::GET, "/opencode/project", repo.path(), None); + let (status, _headers, payload) = send_json_request(&app.app, request).await; + assert_eq!(status, StatusCode::OK, "project list status"); + let list = payload.as_array().cloned().unwrap_or_default(); + assert!( + list.iter() + .any(|project| project.get("id").and_then(Value::as_str) == Some(project_id)), + "project list missing current id" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn opencode_worktree_lifecycle() { + let repo = init_repo(); + let app = TestApp::new(); + + let create_body = json!({"name": "feature-a"}); + let request = opencode_request( + Method::POST, + "/opencode/experimental/worktree", + repo.path(), + Some(create_body), + ); + let (status, _headers, payload) = send_json_request(&app.app, request).await; + assert_eq!(status, StatusCode::OK, "worktree create status"); + + let expected_dir: PathBuf = repo + .path() + .join(".opencode") + .join("worktrees") + .join("feature-a"); + assert_eq!( + payload.get("directory").and_then(Value::as_str), + Some(expected_dir.to_string_lossy().as_ref()) + ); + assert!(expected_dir.exists(), "worktree directory missing"); + + let request = opencode_request(Method::GET, "/opencode/experimental/worktree", repo.path(), None); + let (status, _headers, payload) = send_json_request(&app.app, request).await; + assert_eq!(status, StatusCode::OK, "worktree list status"); + let list = payload.as_array().cloned().unwrap_or_default(); + assert!( + list.iter() + .any(|value| value.as_str() == Some(expected_dir.to_string_lossy().as_ref())), + "worktree list missing new worktree" + ); + + let reset_body = json!({"directory": expected_dir.to_string_lossy()}); + let request = opencode_request( + Method::POST, + "/opencode/experimental/worktree/reset", + repo.path(), + Some(reset_body), + ); + let (status, _headers, payload) = send_json_request(&app.app, request).await; + assert_eq!(status, StatusCode::OK, "worktree reset status"); + assert_eq!(payload, json!(true)); + + let remove_body = json!({"directory": expected_dir.to_string_lossy()}); + let request = opencode_request( + Method::DELETE, + "/opencode/experimental/worktree", + repo.path(), + Some(remove_body), + ); + let (status, _headers, payload) = send_json_request(&app.app, request).await; + assert_eq!(status, StatusCode::OK, "worktree delete status"); + assert_eq!(payload, json!(true)); + assert!(!expected_dir.exists(), "worktree directory not removed"); +} diff --git a/target b/target new file mode 120000 index 0000000..3d6ad8c --- /dev/null +++ b/target @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/target \ No newline at end of file