Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 63 additions & 13 deletions apps/tauri/src-tauri/src/acp/commands.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
use std::collections::HashMap;
use tokio::sync::Mutex;
use tauri::{AppHandle, State};
use tauri::{AppHandle, State, Emitter};

use super::connection::AcpConnection;
use super::types::*;

pub struct AcpState(pub Mutex<HashMap<String, AcpConnection>>);
pub struct AcpState {
pub connections: Mutex<HashMap<String, AcpConnection>>,
pub configs: Mutex<HashMap<String, ConnectionConfig>>,
}

impl Default for AcpState {
fn default() -> Self {
Self(Mutex::new(HashMap::new()))
Self {
connections: Mutex::new(HashMap::new()),
configs: Mutex::new(HashMap::new()),
}
}
}

Expand All @@ -22,21 +28,34 @@ pub async fn acp_connect(
app_handle: AppHandle,
state: State<'_, AcpState>,
) -> Result<(), String> {
let mut connections = state.0.lock().await;
let mut connections = state.connections.lock().await;
if connections.contains_key(&workspace_id) {
return Ok(());
}

let binary = binary_path
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| std::env::var("COPILOT_PATH").unwrap_or_else(|_| "copilot".to_string()));

let config = ConnectionConfig {
binary: binary.clone(),
cwd: cwd.clone(),
gh_token: gh_token.clone(),
};

let _ = app_handle.emit("acp:connection-status", &ConnectionStatusEvent {
workspace_id: workspace_id.clone(),
status: "connecting".to_string(),
attempt: None,
});

let conn = AcpConnection::spawn(
&binary,
&["--acp", "--stdio"],
&cwd,
gh_token,
workspace_id.clone(),
app_handle,
app_handle.clone(),
)
.await?;

Expand All @@ -52,7 +71,9 @@ pub async fn acp_connect(
.await?;

conn.send_notification("initialized", None).await?;
conn.emit_status("connected", None);

state.configs.lock().await.insert(workspace_id.clone(), config);
connections.insert(workspace_id, conn);
Ok(())
}
Expand All @@ -62,7 +83,8 @@ pub async fn acp_disconnect(
workspace_id: String,
state: State<'_, AcpState>,
) -> Result<(), String> {
let mut connections = state.0.lock().await;
state.configs.lock().await.remove(&workspace_id);
let mut connections = state.connections.lock().await;
if let Some(conn) = connections.remove(&workspace_id) {
conn.shutdown().await;
}
Expand All @@ -75,7 +97,7 @@ pub async fn acp_new_session(
cwd: String,
state: State<'_, AcpState>,
) -> Result<SessionInfo, String> {
let connections = state.0.lock().await;
let connections = state.connections.lock().await;
let conn = connections
.get(&workspace_id)
.ok_or("Not connected")?;
Expand Down Expand Up @@ -103,7 +125,7 @@ pub async fn acp_list_sessions(
cwd: String,
state: State<'_, AcpState>,
) -> Result<Vec<SessionSummary>, String> {
let connections = state.0.lock().await;
let connections = state.connections.lock().await;
let conn = connections
.get(&workspace_id)
.ok_or("Not connected")?;
Expand Down Expand Up @@ -133,7 +155,7 @@ pub async fn acp_load_session(
cwd: String,
state: State<'_, AcpState>,
) -> Result<SessionInfo, String> {
let connections = state.0.lock().await;
let connections = state.connections.lock().await;
let conn = connections
.get(&workspace_id)
.ok_or("Not connected")?;
Expand Down Expand Up @@ -167,7 +189,7 @@ pub async fn acp_send_prompt(
text: String,
state: State<'_, AcpState>,
) -> Result<(), String> {
let connections = state.0.lock().await;
let connections = state.connections.lock().await;
let conn = connections
.get(&workspace_id)
.ok_or("Not connected")?;
Expand Down Expand Up @@ -196,7 +218,7 @@ pub async fn acp_set_mode(
mode: String,
state: State<'_, AcpState>,
) -> Result<(), String> {
let connections = state.0.lock().await;
let connections = state.connections.lock().await;
let conn = connections
.get(&workspace_id)
.ok_or("Not connected")?;
Expand All @@ -221,7 +243,7 @@ pub async fn acp_cancel(
session_id: String,
state: State<'_, AcpState>,
) -> Result<(), String> {
let connections = state.0.lock().await;
let connections = state.connections.lock().await;
let conn = connections
.get(&workspace_id)
.ok_or("Not connected")?;
Expand All @@ -237,8 +259,36 @@ pub async fn acp_cancel(
Ok(())
}

#[tauri::command]
pub async fn acp_check_health(
workspace_id: String,
app_handle: AppHandle,
state: State<'_, AcpState>,
) -> Result<String, String> {
let connections = state.connections.lock().await;
let status = if let Some(conn) = connections.get(&workspace_id) {
if conn.is_alive().await {
conn.emit_status("connected", None);
"connected"
} else {
conn.emit_status("disconnected", None);
"disconnected"
}
} else {
let event = ConnectionStatusEvent {
workspace_id: workspace_id.clone(),
status: "disconnected".to_string(),
attempt: None,
};
let _ = app_handle.emit("acp:connection-status", &event);
"disconnected"
};
Ok(status.to_string())
}

pub async fn disconnect_all(state: &AcpState) {
let mut connections = state.0.lock().await;
state.configs.lock().await.clear();
let mut connections = state.connections.lock().await;
for (id, conn) in connections.drain() {
eprintln!("[acp] Disconnecting workspace {}", id);
conn.shutdown().await;
Expand Down
84 changes: 80 additions & 4 deletions apps/tauri/src-tauri/src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ use tauri::{AppHandle, Emitter};
use super::types::*;

type PendingMap = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<serde_json::Value, JsonRpcError>>>>>;
type ChildRef = Arc<Mutex<Option<Child>>>;

pub struct AcpConnection {
child: Mutex<Option<Child>>,
child: ChildRef,
writer_tx: mpsc::Sender<String>,
pending: PendingMap,
next_id: AtomicU64,
reader_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
writer_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
heartbeat_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
app_handle: AppHandle,
workspace_id: String,
}

impl AcpConnection {
Expand Down Expand Up @@ -55,17 +59,27 @@ impl AcpConnection {
stdout,
pending.clone(),
writer_tx.clone(),
workspace_id,
app_handle,
workspace_id.clone(),
app_handle.clone(),
));

let child_arc: ChildRef = Arc::new(Mutex::new(Some(child)));
let heartbeat_handle = tokio::spawn(Self::heartbeat_task(
child_arc.clone(),
workspace_id.clone(),
app_handle.clone(),
));

Ok(Self {
child: Mutex::new(Some(child)),
child: child_arc,
writer_tx,
pending,
next_id: AtomicU64::new(1),
reader_handle: Mutex::new(Some(reader_handle)),
writer_handle: Mutex::new(Some(writer_handle)),
heartbeat_handle: Mutex::new(Some(heartbeat_handle)),
app_handle,
workspace_id,
})
}

Expand Down Expand Up @@ -155,6 +169,43 @@ impl AcpConnection {
}
}
eprintln!("[acp] Reader task ended for workspace {}", workspace_id);
let event = ConnectionStatusEvent {
workspace_id: workspace_id.clone(),
status: "disconnected".to_string(),
attempt: None,
};
let _ = app_handle.emit("acp:connection-status", &event);
}

async fn heartbeat_task(child: ChildRef, workspace_id: String, app_handle: AppHandle) {
let interval = std::time::Duration::from_secs(15);
loop {
tokio::time::sleep(interval).await;
let mut guard = child.lock().await;
if let Some(ref mut c) = *guard {
match c.try_wait() {
Ok(Some(status)) => {
eprintln!("[acp] Heartbeat: process exited (status: {:?}) for workspace {}", status, workspace_id);
*guard = None;
drop(guard);
let event = ConnectionStatusEvent {
workspace_id,
status: "disconnected".to_string(),
attempt: None,
};
let _ = app_handle.emit("acp:connection-status", &event);
return;
}
Ok(None) => {} // still running
Err(e) => {
eprintln!("[acp] Heartbeat: try_wait error for workspace {}: {}", workspace_id, e);
}
}
} else {
// child was already taken (shutdown called)
return;
}
}
}

fn handle_session_update(
Expand Down Expand Up @@ -241,6 +292,11 @@ impl AcpConnection {
}
drop(child_guard);

let mut heartbeat = self.heartbeat_handle.lock().await;
if let Some(handle) = heartbeat.take() {
handle.abort();
}

let mut reader = self.reader_handle.lock().await;
if let Some(handle) = reader.take() {
handle.abort();
Expand All @@ -253,4 +309,24 @@ impl AcpConnection {

self.pending.lock().await.clear();
}

/// Returns true if the child process is still running.
pub async fn is_alive(&self) -> bool {
let mut guard = self.child.lock().await;
if let Some(ref mut c) = *guard {
matches!(c.try_wait(), Ok(None))
} else {
false
}
}

/// Emits a connection-status event to the frontend.
pub fn emit_status(&self, status: &str, attempt: Option<u32>) {
let event = ConnectionStatusEvent {
workspace_id: self.workspace_id.clone(),
status: status.to_string(),
attempt,
};
let _ = self.app_handle.emit("acp:connection-status", &event);
}
}
17 changes: 17 additions & 0 deletions apps/tauri/src-tauri/src/acp/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,20 @@ pub struct SessionUpdateEvent {
pub update_type: String,
pub payload: serde_json::Value,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionStatusEvent {
pub workspace_id: String,
pub status: String, // "connecting" | "connected" | "disconnected" | "reconnecting"
#[serde(skip_serializing_if = "Option::is_none")]
pub attempt: Option<u32>,
}

#[derive(Debug, Clone)]
#[allow(dead_code)] // stored for future reconnect support
pub struct ConnectionConfig {
pub binary: String,
pub cwd: String,
pub gh_token: Option<String>,
}
Loading