mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 08:03:46 +00:00
feat: implement project/worktree model and opencode endpoints tests
This commit is contained in:
parent
7378abee46
commit
83457e50b0
9 changed files with 856 additions and 62 deletions
1
.turbo
Symbolic link
1
.turbo
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/nathan/sandbox-agent/.turbo
|
||||
1
dist
Symbolic link
1
dist
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/nathan/sandbox-agent/dist
|
||||
1
node_modules
Symbolic link
1
node_modules
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/nathan/sandbox-agent/node_modules
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
435
server/packages/sandbox-agent/src/project_manager.rs
Normal file
435
server/packages/sandbox-agent/src/project_manager.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
159
server/packages/sandbox-agent/tests/opencode_project_worktree.rs
Normal file
159
server/packages/sandbox-agent/tests/opencode_project_worktree.rs
Normal 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
1
target
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/nathan/sandbox-agent/target
|
||||
Loading…
Add table
Add a link
Reference in a new issue