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..c4f336a 100644 --- a/server/packages/sandbox-agent/src/lib.rs +++ b/server/packages/sandbox-agent/src/lib.rs @@ -7,3 +7,4 @@ pub mod router; pub mod server_logs; pub mod telemetry; pub mod ui; +pub mod vcs; diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs index 55b7050..08de370 100644 --- a/server/packages/sandbox-agent/src/opencode_compat.rs +++ b/server/packages/sandbox-agent/src/opencode_compat.rs @@ -31,6 +31,7 @@ use sandbox_agent_universal_agent_schema::{ UniversalEventType, UniversalItem, PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus, FileAction, ItemStatus, }; +use crate::vcs::VcsFileStatus; static SESSION_COUNTER: AtomicU64 = AtomicU64::new(1); static MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1); @@ -466,6 +467,13 @@ struct DirectoryQuery { directory: Option, } +#[derive(Debug, Deserialize, IntoParams)] +struct DiffQuery { + directory: Option, + #[serde(rename = "messageID")] + message_id: Option, +} + #[derive(Debug, Deserialize, IntoParams)] struct ToolQuery { directory: Option, @@ -497,6 +505,15 @@ struct FileContentQuery { path: Option, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct SessionRevertRequest { + #[serde(rename = "messageID")] + message_id: Option, + #[serde(rename = "partID")] + part_id: Option, +} + #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] struct SessionMessageRequest { @@ -783,6 +800,14 @@ fn bool_ok(value: bool) -> (StatusCode, Json) { (StatusCode::OK, Json(json!(value))) } +fn vcs_status_label(status: &VcsFileStatus) -> &'static str { + match status { + VcsFileStatus::Added => "added", + VcsFileStatus::Deleted => "deleted", + VcsFileStatus::Modified => "modified", + } +} + fn build_user_message( session_id: &str, message_id: &str, @@ -2528,13 +2553,21 @@ 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 vcs = state.inner.session_manager().vcs(); + let status = tokio::task::spawn_blocking(move || vcs.status(&directory)) + .await + .ok() + .flatten(); + let branch = status + .map(|status| status.branch) + .unwrap_or_else(|| state.opencode.branch_name()); + (StatusCode::OK, Json(json!({ "branch": branch }))) } #[utoipa::path( @@ -2829,8 +2862,33 @@ async fn oc_session_fork( responses((status = 200)), tag = "opencode" )] -async fn oc_session_diff() -> impl IntoResponse { - (StatusCode::OK, Json(json!([]))) +async fn oc_session_diff( + State(state): State>, + Path(_session_id): Path, + headers: HeaderMap, + Query(query): Query, +) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let vcs = state.inner.session_manager().vcs(); + let diffs = tokio::task::spawn_blocking(move || vcs.diff(&directory)) + .await + .ok() + .flatten() + .unwrap_or_default(); + let response: Vec = diffs + .into_iter() + .map(|diff| { + json!({ + "file": diff.file, + "before": diff.before, + "after": diff.after, + "additions": diff.additions, + "deletions": diff.deletions, + "status": vcs_status_label(&diff.status), + }) + }) + .collect(); + (StatusCode::OK, Json(json!(response))) } #[utoipa::path( @@ -3236,7 +3294,7 @@ async fn oc_session_shell( post, path = "/session/{sessionID}/revert", params(("sessionID" = String, Path, description = "Session ID")), - request_body = String, + request_body = SessionRevertRequest, responses((status = 200)), tag = "opencode" )] @@ -3245,7 +3303,19 @@ async fn oc_session_revert( Path(session_id): Path, headers: HeaderMap, Query(query): Query, + _body: Option>, ) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let vcs = state.inner.session_manager().vcs(); + let session_id_clone = session_id.clone(); + let revert_result = tokio::task::spawn_blocking(move || vcs.revert(&directory, &session_id_clone)) + .await + .map_err(|err| SandboxError::InvalidRequest { + message: err.to_string(), + }); + if let Ok(Err(err)) | Err(err) = revert_result { + return internal_error(&err.to_string()).into_response(); + } oc_session_get(State(state), Path(session_id), headers, Query(query)).await } @@ -3253,7 +3323,6 @@ async fn oc_session_revert( post, path = "/session/{sessionID}/unrevert", params(("sessionID" = String, Path, description = "Session ID")), - request_body = String, responses((status = 200)), tag = "opencode" )] @@ -3263,6 +3332,18 @@ async fn oc_session_unrevert( headers: HeaderMap, Query(query): Query, ) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let vcs = state.inner.session_manager().vcs(); + let session_id_clone = session_id.clone(); + let unrevert_result = + tokio::task::spawn_blocking(move || vcs.unrevert(&directory, &session_id_clone)) + .await + .map_err(|err| SandboxError::InvalidRequest { + message: err.to_string(), + }); + if let Ok(Err(err)) | Err(err) = unrevert_result { + return internal_error(&err.to_string()).into_response(); + } oc_session_get(State(state), Path(session_id), headers, Query(query)).await } @@ -3784,8 +3865,30 @@ async fn oc_file_content(Query(query): Query) -> impl IntoResp responses((status = 200)), tag = "opencode" )] -async fn oc_file_status() -> impl IntoResponse { - (StatusCode::OK, Json(json!([]))).into_response() +async fn oc_file_status( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let vcs = state.inner.session_manager().vcs(); + let summaries = tokio::task::spawn_blocking(move || vcs.file_status(&directory)) + .await + .ok() + .flatten() + .unwrap_or_default(); + let response: Vec = summaries + .into_iter() + .map(|summary| { + json!({ + "path": summary.path, + "added": summary.added, + "removed": summary.removed, + "status": vcs_status_label(&summary.status), + }) + }) + .collect(); + (StatusCode::OK, Json(json!(response))).into_response() } #[utoipa::path( @@ -4264,6 +4367,7 @@ async fn oc_tui_select_session() -> impl IntoResponse { SessionCommandRequest, SessionShellRequest, SessionSummarizeRequest, + SessionRevertRequest, PermissionReplyRequest, PermissionGlobalReplyRequest, QuestionReplyBody, diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 3ca437a..714a71a 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::vcs::VcsService; 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, + vcs: 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(), + vcs: Arc::new(VcsService::new()), } } + pub(crate) fn vcs(&self) -> Arc { + self.vcs.clone() + } + fn session_ref<'a>(sessions: &'a [SessionState], session_id: &str) -> Option<&'a SessionState> { sessions .iter() diff --git a/server/packages/sandbox-agent/src/vcs.rs b/server/packages/sandbox-agent/src/vcs.rs new file mode 100644 index 0000000..4d7ad07 --- /dev/null +++ b/server/packages/sandbox-agent/src/vcs.rs @@ -0,0 +1,365 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Mutex; + +use sandbox_agent_error::SandboxError; + +#[derive(Debug, Clone)] +pub struct VcsStatus { + pub branch: String, + pub ahead: u32, + pub behind: u32, + pub dirty_files: Vec, +} + +#[derive(Debug, Clone)] +pub enum VcsFileStatus { + Added, + Deleted, + Modified, +} + +#[derive(Debug, Clone)] +pub struct VcsFileDiff { + pub file: String, + pub before: String, + pub after: String, + pub additions: u32, + pub deletions: u32, + pub status: VcsFileStatus, +} + +#[derive(Debug, Clone)] +pub struct VcsFileSummary { + pub path: String, + pub added: u32, + pub removed: u32, + pub status: VcsFileStatus, +} + +#[derive(Debug, Clone)] +struct StatusEntry { + status: String, + path: String, +} + +#[derive(Debug, Clone)] +struct BranchInfo { + branch: String, + ahead: u32, + behind: u32, +} + +#[derive(Debug, Clone)] +struct VcsStashInfo { + repo_root: PathBuf, + stash_ref: String, +} + +#[derive(Debug, Default)] +pub struct VcsService { + stashes: Mutex>, +} + +impl VcsService { + pub fn new() -> Self { + Self { + stashes: Mutex::new(HashMap::new()), + } + } + + pub fn discover_repo(&self, directory: &str) -> Option { + let root = run_git(Path::new(directory), &["rev-parse", "--show-toplevel"]).ok()?; + let root = root.trim(); + if root.is_empty() { + return None; + } + Some(PathBuf::from(root)) + } + + pub fn status(&self, directory: &str) -> Option { + let repo_root = self.discover_repo(directory)?; + let (branch_info, entries) = status_entries(&repo_root)?; + let branch_info = branch_info.unwrap_or_else(|| BranchInfo { + branch: "HEAD".to_string(), + ahead: 0, + behind: 0, + }); + let dirty_files = entries.into_iter().map(|entry| entry.path).collect(); + Some(VcsStatus { + branch: branch_info.branch, + ahead: branch_info.ahead, + behind: branch_info.behind, + dirty_files, + }) + } + + pub fn diff(&self, directory: &str) -> Option> { + let repo_root = self.discover_repo(directory)?; + let (_branch, entries) = status_entries(&repo_root)?; + let mut diffs = Vec::new(); + for entry in entries { + let status = status_from_codes(&entry.status); + let before = match status { + VcsFileStatus::Added => String::new(), + VcsFileStatus::Deleted | VcsFileStatus::Modified => { + git_show_file(&repo_root, &entry.path).unwrap_or_default() + } + }; + let after = match status { + VcsFileStatus::Deleted => String::new(), + VcsFileStatus::Added | VcsFileStatus::Modified => { + read_file(&repo_root, &entry.path).unwrap_or_default() + } + }; + let (additions, deletions) = match numstat_for_file(&repo_root, &entry.path) { + Some((adds, dels)) => (adds, dels), + None => match status { + VcsFileStatus::Added => (count_lines(&after), 0), + VcsFileStatus::Deleted => (0, count_lines(&before)), + VcsFileStatus::Modified => (count_lines(&after), count_lines(&before)), + }, + }; + diffs.push(VcsFileDiff { + file: entry.path, + before, + after, + additions, + deletions, + status, + }); + } + Some(diffs) + } + + pub fn diff_text(&self, directory: &str) -> Option { + let repo_root = self.discover_repo(directory)?; + run_git(&repo_root, &["diff", "HEAD"]).ok() + } + + pub fn file_status(&self, directory: &str) -> Option> { + let repo_root = self.discover_repo(directory)?; + let (_branch, entries) = status_entries(&repo_root)?; + let mut summaries = Vec::new(); + for entry in entries { + let status = status_from_codes(&entry.status); + let (added, removed) = match numstat_for_file(&repo_root, &entry.path) { + Some((adds, dels)) => (adds, dels), + None => match status { + VcsFileStatus::Added => { + let after = read_file(&repo_root, &entry.path).unwrap_or_default(); + (count_lines(&after), 0) + } + VcsFileStatus::Deleted => { + let before = git_show_file(&repo_root, &entry.path).unwrap_or_default(); + (0, count_lines(&before)) + } + VcsFileStatus::Modified => (0, 0), + }, + }; + summaries.push(VcsFileSummary { + path: entry.path, + added, + removed, + status, + }); + } + Some(summaries) + } + + pub fn revert(&self, directory: &str, session_id: &str) -> Result { + let repo_root = match self.discover_repo(directory) { + Some(root) => root, + None => return Ok(false), + }; + let key = stash_key(&repo_root, session_id); + { + let stashes = self.stashes.lock().unwrap(); + if stashes.contains_key(&key) { + return Ok(true); + } + } + + let message = format!("sandbox-agent:session:{session_id}:revert"); + let output = run_git(&repo_root, &["stash", "push", "-u", "-m", &message])?; + if output.contains("No local changes to save") { + return Ok(false); + } + let stash_ref = find_stash_ref(&repo_root, &message)?; + let mut stashes = self.stashes.lock().unwrap(); + stashes.insert( + key, + VcsStashInfo { + repo_root, + stash_ref, + }, + ); + Ok(true) + } + + pub fn unrevert(&self, directory: &str, session_id: &str) -> Result { + let repo_root = match self.discover_repo(directory) { + Some(root) => root, + None => return Ok(false), + }; + let key = stash_key(&repo_root, session_id); + let stash = { + let mut stashes = self.stashes.lock().unwrap(); + stashes.remove(&key) + }; + let Some(stash) = stash else { + return Ok(false); + }; + run_git(&stash.repo_root, &["stash", "apply", &stash.stash_ref])?; + let _ = run_git(&stash.repo_root, &["stash", "drop", &stash.stash_ref]); + Ok(true) + } +} + +fn stash_key(repo_root: &Path, session_id: &str) -> String { + format!("{}::{}", repo_root.display(), session_id) +} + +fn run_git(directory: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(directory) + .args(args) + .output() + .map_err(|err| SandboxError::InvalidRequest { + message: format!("git execution failed: {err}"), + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(SandboxError::InvalidRequest { + message: format!("git command failed: {stderr}"), + }); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn status_entries(repo_root: &Path) -> Option<(Option, Vec)> { + let output = run_git(repo_root, &["status", "--porcelain=v1", "-b", "-z"]).ok()?; + let mut entries = Vec::new(); + let mut branch_info = None; + let mut iter = output.split_terminator('\0'); + while let Some(entry) = iter.next() { + if entry.is_empty() { + continue; + } + if let Some(info) = parse_branch_line(entry) { + branch_info = Some(info); + continue; + } + if entry.len() < 3 { + continue; + } + let status = entry[..2].to_string(); + let mut path = entry[3..].to_string(); + if status.starts_with('R') || status.starts_with('C') { + if let Some(new_path) = iter.next() { + path = new_path.to_string(); + } + } + entries.push(StatusEntry { status, path }); + } + Some((branch_info, entries)) +} + +fn parse_branch_line(line: &str) -> Option { + if !line.starts_with("## ") { + return None; + } + let line = line.trim_start_matches("## ").trim(); + let mut branch = line; + let mut ahead = 0; + let mut behind = 0; + if let Some((name, rest)) = line.split_once("...") { + branch = name; + if let Some((start, end)) = rest.find('[').zip(rest.find(']')) { + let stats = &rest[start + 1..end]; + for part in stats.split(',') { + let part = part.trim(); + if let Some(count) = part.strip_prefix("ahead ") { + ahead = count.parse::().unwrap_or(0); + } else if let Some(count) = part.strip_prefix("behind ") { + behind = count.parse::().unwrap_or(0); + } + } + } + } + if branch.starts_with("HEAD") { + branch = "HEAD"; + } + Some(BranchInfo { + branch: branch.to_string(), + ahead, + behind, + }) +} + +fn status_from_codes(status: &str) -> VcsFileStatus { + let bytes = status.as_bytes(); + if bytes.len() < 2 { + return VcsFileStatus::Modified; + } + let index = bytes[0] as char; + let worktree = bytes[1] as char; + if index == 'A' || worktree == 'A' || index == '?' || worktree == '?' { + VcsFileStatus::Added + } else if index == 'D' || worktree == 'D' { + VcsFileStatus::Deleted + } else { + VcsFileStatus::Modified + } +} + +fn git_show_file(repo_root: &Path, path: &str) -> Option { + let output = run_git(repo_root, &["show", &format!("HEAD:{path}")]).ok()?; + Some(output) +} + +fn read_file(repo_root: &Path, path: &str) -> Option { + let full_path = repo_root.join(path); + let bytes = std::fs::read(full_path).ok()?; + Some(String::from_utf8_lossy(&bytes).to_string()) +} + +fn numstat_for_file(repo_root: &Path, path: &str) -> Option<(u32, u32)> { + let output = run_git(repo_root, &["diff", "--numstat", "HEAD", "--", path]).ok()?; + let line = output.lines().next()?; + let mut parts = line.split('\t'); + let adds = parts.next()?; + let dels = parts.next()?; + let parse_field = |value: &str| -> Option { + if value == "-" { + Some(0) + } else { + value.parse::().ok() + } + }; + Some((parse_field(adds)?, parse_field(dels)?)) +} + +fn count_lines(text: &str) -> u32 { + if text.is_empty() { + 0 + } else { + text.lines().count() as u32 + } +} + +fn find_stash_ref(repo_root: &Path, message: &str) -> Result { + let output = run_git(repo_root, &["stash", "list", "--format=%gd:%s"])?; + for line in output.lines() { + if let Some((stash_ref, subject)) = line.split_once(':') { + if subject.trim() == message { + return Ok(stash_ref.to_string()); + } + } + } + Err(SandboxError::InvalidRequest { + message: "unable to locate stash reference".to_string(), + }) +} diff --git a/server/packages/sandbox-agent/tests/opencode-compat/vcs.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/vcs.test.ts new file mode 100644 index 0000000..8c9f1d2 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/vcs.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for OpenCode-compatible VCS endpoints. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn"; + +async function createFixtureRepo(): Promise<{ dir: string; filePath: string }> { + const dir = await mkdtemp(join(tmpdir(), "opencode-vcs-")); + execFileSync("git", ["init", "-b", "main"], { cwd: dir }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: dir }); + const filePath = join(dir, "README.md"); + await writeFile(filePath, "hello\n", "utf8"); + execFileSync("git", ["add", "."], { cwd: dir }); + execFileSync("git", ["commit", "-m", "init"], { cwd: dir }); + return { dir, filePath }; +} + +describe("OpenCode-compatible VCS API", () => { + let handle: SandboxAgentHandle; + let client: OpencodeClient; + let repoDir: string; + let filePath: string; + + beforeAll(async () => { + await buildSandboxAgent(); + }); + + beforeEach(async () => { + const repo = await createFixtureRepo(); + repoDir = repo.dir; + filePath = repo.filePath; + + handle = await spawnSandboxAgent({ + opencodeCompat: true, + env: { + OPENCODE_COMPAT_DIRECTORY: repoDir, + OPENCODE_COMPAT_WORKTREE: repoDir, + OPENCODE_COMPAT_BRANCH: "main", + }, + }); + + client = createOpencodeClient({ + baseUrl: `${handle.baseUrl}/opencode`, + headers: { Authorization: `Bearer ${handle.token}` }, + }); + }); + + afterEach(async () => { + await handle?.dispose(); + if (repoDir) { + await rm(repoDir, { recursive: true, force: true }); + } + }); + + it("returns branch and diff entries", async () => { + const vcs = await client.vcs.get({ query: { directory: repoDir } }); + expect(vcs.data?.branch).toBe("main"); + + const session = await client.session.create(); + const sessionId = session.data?.id!; + + await writeFile(filePath, "hello\nworld\n", "utf8"); + + const diff = await client.session.diff({ + path: { sessionID: sessionId }, + query: { directory: repoDir }, + }); + + expect(diff.data?.length).toBe(1); + const entry = diff.data?.[0]!; + expect(entry.file).toBe("README.md"); + expect(entry.before).toContain("hello"); + expect(entry.after).toContain("world"); + expect(entry.additions).toBeGreaterThan(0); + }); + + it("reverts and unreverts working tree changes", async () => { + const session = await client.session.create(); + const sessionId = session.data?.id!; + + await writeFile(filePath, "hello\nchanged\n", "utf8"); + + await client.session.revert({ + path: { sessionID: sessionId }, + query: { directory: repoDir }, + body: { messageID: "msg_test" }, + }); + + const reverted = await readFile(filePath, "utf8"); + expect(reverted).toBe("hello\n"); + + await client.session.unrevert({ + path: { sessionID: sessionId }, + query: { directory: repoDir }, + }); + + const restored = await readFile(filePath, "utf8"); + expect(restored).toBe("hello\nchanged\n"); + }); +}); 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