feat: implement project/worktree model and opencode endpoints tests

This commit is contained in:
Nathan Flurry 2026-02-04 14:32:53 -08:00
parent 7378abee46
commit 83457e50b0
9 changed files with 856 additions and 62 deletions

1
.turbo Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/.turbo

1
dist Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/dist

1
node_modules Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/node_modules

View file

@ -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;

View file

@ -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<String>) -> 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<bool>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
struct ProjectUpdateInput {
name: Option<String>,
icon: Option<ProjectIconInput>,
commands: Option<ProjectCommandsInput>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ProjectIconInput {
url: Option<String>,
#[serde(rename = "override")]
override_name: Option<String>,
color: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ProjectCommandsInput {
start: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct PermissionReplyRequest {
response: Option<String>,
@ -561,6 +583,24 @@ struct QuestionReplyBody {
answers: Option<Vec<Vec<String>>>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
struct WorktreeCreateInput {
name: Option<String>,
#[serde(rename = "startCommand")]
start_command: Option<String>,
}
#[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<Value>) {
(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<Arc<OpenCodeAppState>>, headers: HeaderMap)
responses((status = 200)),
tag = "opencode"
)]
async fn oc_vcs(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse {
(
StatusCode::OK,
Json(json!({
"branch": state.opencode.branch_name(),
})),
)
async fn oc_vcs(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
Query(query): Query<DirectoryQuery>,
) -> 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<Arc<OpenCodeAppState>>) -> impl IntoResponse
responses((status = 200)),
tag = "opencode"
)]
async fn oc_project_list(State(state): State<Arc<OpenCodeAppState>>, 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<Arc<OpenCodeAppState>>,
headers: HeaderMap,
Query(query): Query<DirectoryQuery>,
) -> 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<Value> = 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<Arc<OpenCodeAppState>>, headers: He
responses((status = 200)),
tag = "opencode"
)]
async fn oc_project_current(State(state): State<Arc<OpenCodeAppState>>, 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<Arc<OpenCodeAppState>>,
headers: HeaderMap,
Query(query): Query<DirectoryQuery>,
) -> 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<Arc<OpenCodeAppState>>,
Path(_project_id): Path<String>,
headers: HeaderMap,
Query(query): Query<DirectoryQuery>,
body: Option<Json<ProjectUpdateInput>>,
) -> 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<DirectoryQuery>,
) -> 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<Arc<OpenCodeAppState>>, 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<Arc<OpenCodeAppState>>,
headers: HeaderMap,
Query(query): Query<DirectoryQuery>,
) -> 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<Arc<OpenCodeAppState>>, 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<Arc<OpenCodeAppState>>,
headers: HeaderMap,
Query(query): Query<DirectoryQuery>,
body: Option<Json<WorktreeCreateInput>>,
) -> 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<Arc<OpenCodeAppState>>, headers:
responses((status = 200)),
tag = "opencode"
)]
async fn oc_worktree_delete() -> impl IntoResponse {
bool_ok(true)
async fn oc_worktree_delete(
State(state): State<Arc<OpenCodeAppState>>,
body: Option<Json<WorktreeRemoveInput>>,
) -> 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<Arc<OpenCodeAppState>>,
body: Option<Json<WorktreeResetInput>>,
) -> 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"))

View file

@ -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<String>,
pub(crate) override_name: Option<String>,
pub(crate) color: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub(crate) struct ProjectCommands {
pub(crate) start: Option<String>,
}
#[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<ProjectIcon>,
pub(crate) commands: Option<ProjectCommands>,
}
#[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<String, ProjectRecord>,
project_id_by_root: HashMap<String, String>,
}
#[derive(Clone, Debug)]
struct ProjectRecord {
id: String,
root: String,
name: String,
created_at: i64,
updated_at: i64,
icon: Option<ProjectIcon>,
commands: Option<ProjectCommands>,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ProjectUpdate {
pub(crate) name: Option<String>,
pub(crate) icon: Option<ProjectIcon>,
pub(crate) commands: Option<ProjectCommands>,
}
pub(crate) struct ProjectManager {
state: Mutex<ProjectState>,
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<ProjectInfo, SandboxError> {
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<Vec<ProjectInfo>, 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<ProjectInfo, SandboxError> {
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<Vec<String>, 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<String>,
) -> Result<WorktreeInfo, SandboxError> {
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<PathBuf, SandboxError> {
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<Vec<String>, 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<Path>) -> Option<String> {
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<String> {
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<String, SandboxError> {
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<PathBuf> {
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)
}

View file

@ -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<Vec<SessionState>>,
server_manager: Arc<AgentServerManager>,
http_client: Client,
project_manager: Arc<ProjectManager>,
}
/// 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<ProjectManager> {
self.project_manager.clone()
}
fn session_ref<'a>(sessions: &'a [SessionState], session_id: &str) -> Option<&'a SessionState> {
sessions
.iter()

View file

@ -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<Value>,
) -> Request<Body> {
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");
}

1
target Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/target