feat(v2): add SidecarManager and agent Tauri commands
Implement Rust SidecarManager that spawns Node.js sidecar process, communicates via stdio NDJSON, and manages agent session lifecycle. Add agent_query, agent_stop, agent_ready Tauri commands. Sidecar auto-starts on app launch.
This commit is contained in:
parent
54b0d44bc1
commit
f0ec44f6a6
2 changed files with 257 additions and 3 deletions
|
|
@ -4,13 +4,17 @@ mod watcher;
|
||||||
mod session;
|
mod session;
|
||||||
|
|
||||||
use pty::{PtyManager, PtyOptions};
|
use pty::{PtyManager, PtyOptions};
|
||||||
|
use sidecar::{AgentQueryOptions, SidecarManager};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
pty_manager: Arc<PtyManager>,
|
pty_manager: Arc<PtyManager>,
|
||||||
|
sidecar_manager: Arc<SidecarManager>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- PTY commands ---
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn pty_spawn(
|
fn pty_spawn(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
|
|
@ -40,10 +44,34 @@ fn pty_kill(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||||
state.pty_manager.kill(&id)
|
state.pty_manager.kill(&id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Agent/sidecar commands ---
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn agent_query(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
options: AgentQueryOptions,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.sidecar_manager.query(&options)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn agent_stop(state: State<'_, AppState>, session_id: String) -> Result<(), String> {
|
||||||
|
state.sidecar_manager.stop_session(&session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn agent_ready(state: State<'_, AppState>) -> bool {
|
||||||
|
state.sidecar_manager.is_ready()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
let pty_manager = Arc::new(PtyManager::new());
|
||||||
|
let sidecar_manager = Arc::new(SidecarManager::new());
|
||||||
|
|
||||||
let app_state = AppState {
|
let app_state = AppState {
|
||||||
pty_manager: Arc::new(PtyManager::new()),
|
pty_manager,
|
||||||
|
sidecar_manager: sidecar_manager.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
|
@ -53,8 +81,11 @@ pub fn run() {
|
||||||
pty_write,
|
pty_write,
|
||||||
pty_resize,
|
pty_resize,
|
||||||
pty_kill,
|
pty_kill,
|
||||||
|
agent_query,
|
||||||
|
agent_stop,
|
||||||
|
agent_ready,
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(move |app| {
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
app.handle().plugin(
|
app.handle().plugin(
|
||||||
tauri_plugin_log::Builder::default()
|
tauri_plugin_log::Builder::default()
|
||||||
|
|
@ -62,6 +93,13 @@ pub fn run() {
|
||||||
.build(),
|
.build(),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start sidecar on app launch
|
||||||
|
match sidecar_manager.start(app.handle()) {
|
||||||
|
Ok(()) => log::info!("Sidecar startup initiated"),
|
||||||
|
Err(e) => log::warn!("Sidecar startup failed (agent features unavailable): {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,218 @@
|
||||||
// Node.js sidecar lifecycle management
|
// Node.js sidecar lifecycle management
|
||||||
// Phase 3: spawn, restart, health check, stdio NDJSON communication
|
// Spawns agent-runner.ts (compiled), communicates via stdio NDJSON
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AgentQueryOptions {
|
||||||
|
pub session_id: String,
|
||||||
|
pub prompt: String,
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
pub max_turns: Option<u32>,
|
||||||
|
pub max_budget_usd: Option<f64>,
|
||||||
|
pub resume_session_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SidecarManager {
|
||||||
|
child: Arc<Mutex<Option<Child>>>,
|
||||||
|
stdin_writer: Arc<Mutex<Option<Box<dyn Write + Send>>>>,
|
||||||
|
ready: Arc<Mutex<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SidecarManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
child: Arc::new(Mutex::new(None)),
|
||||||
|
stdin_writer: Arc::new(Mutex::new(None)),
|
||||||
|
ready: Arc::new(Mutex::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(&self, app: &AppHandle) -> Result<(), String> {
|
||||||
|
let mut child_lock = self.child.lock().unwrap();
|
||||||
|
if child_lock.is_some() {
|
||||||
|
return Err("Sidecar already running".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve sidecar binary path relative to the app
|
||||||
|
let sidecar_path = Self::resolve_sidecar_path(app)?;
|
||||||
|
|
||||||
|
log::info!("Starting sidecar: node {}", sidecar_path.display());
|
||||||
|
|
||||||
|
let mut child = Command::new("node")
|
||||||
|
.arg(&sidecar_path)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("Failed to start sidecar: {e}"))?;
|
||||||
|
|
||||||
|
let child_stdin = child.stdin.take().ok_or("Failed to capture sidecar stdin")?;
|
||||||
|
let child_stdout = child.stdout.take().ok_or("Failed to capture sidecar stdout")?;
|
||||||
|
let child_stderr = child.stderr.take().ok_or("Failed to capture sidecar stderr")?;
|
||||||
|
|
||||||
|
*self.stdin_writer.lock().unwrap() = Some(Box::new(child_stdin));
|
||||||
|
|
||||||
|
// Stdout reader thread — forwards NDJSON to Tauri events
|
||||||
|
let app_handle = app.clone();
|
||||||
|
let ready = self.ready.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let reader = BufReader::new(child_stdout);
|
||||||
|
for line in reader.lines() {
|
||||||
|
match line {
|
||||||
|
Ok(line) => {
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match serde_json::from_str::<serde_json::Value>(&line) {
|
||||||
|
Ok(msg) => {
|
||||||
|
// Check for ready signal
|
||||||
|
if msg.get("type").and_then(|t| t.as_str()) == Some("ready") {
|
||||||
|
*ready.lock().unwrap() = true;
|
||||||
|
log::info!("Sidecar ready");
|
||||||
|
}
|
||||||
|
let _ = app_handle.emit("sidecar-message", &msg);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Invalid JSON from sidecar: {e}: {line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Sidecar stdout read error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log::info!("Sidecar stdout reader exited");
|
||||||
|
let _ = app_handle.emit("sidecar-exited", ());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stderr reader thread — logs only
|
||||||
|
thread::spawn(move || {
|
||||||
|
let reader = BufReader::new(child_stderr);
|
||||||
|
for line in reader.lines() {
|
||||||
|
match line {
|
||||||
|
Ok(line) => log::info!("[sidecar stderr] {line}"),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Sidecar stderr read error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
*child_lock = Some(child);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_message(&self, msg: &serde_json::Value) -> Result<(), String> {
|
||||||
|
let mut writer_lock = self.stdin_writer.lock().unwrap();
|
||||||
|
let writer = writer_lock
|
||||||
|
.as_mut()
|
||||||
|
.ok_or("Sidecar not running")?;
|
||||||
|
|
||||||
|
let line = serde_json::to_string(msg)
|
||||||
|
.map_err(|e| format!("JSON serialize error: {e}"))?;
|
||||||
|
|
||||||
|
writer
|
||||||
|
.write_all(line.as_bytes())
|
||||||
|
.map_err(|e| format!("Sidecar write error: {e}"))?;
|
||||||
|
writer
|
||||||
|
.write_all(b"\n")
|
||||||
|
.map_err(|e| format!("Sidecar write error: {e}"))?;
|
||||||
|
writer
|
||||||
|
.flush()
|
||||||
|
.map_err(|e| format!("Sidecar flush error: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn query(&self, options: &AgentQueryOptions) -> Result<(), String> {
|
||||||
|
if !*self.ready.lock().unwrap() {
|
||||||
|
return Err("Sidecar not ready".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = serde_json::json!({
|
||||||
|
"type": "query",
|
||||||
|
"sessionId": options.session_id,
|
||||||
|
"prompt": options.prompt,
|
||||||
|
"cwd": options.cwd,
|
||||||
|
"maxTurns": options.max_turns,
|
||||||
|
"maxBudgetUsd": options.max_budget_usd,
|
||||||
|
"resumeSessionId": options.resume_session_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
self.send_message(&msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_session(&self, session_id: &str) -> Result<(), String> {
|
||||||
|
let msg = serde_json::json!({
|
||||||
|
"type": "stop",
|
||||||
|
"sessionId": session_id,
|
||||||
|
});
|
||||||
|
self.send_message(&msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shutdown(&self) -> Result<(), String> {
|
||||||
|
let mut child_lock = self.child.lock().unwrap();
|
||||||
|
if let Some(ref mut child) = *child_lock {
|
||||||
|
log::info!("Shutting down sidecar");
|
||||||
|
// Drop stdin to signal EOF
|
||||||
|
*self.stdin_writer.lock().unwrap() = None;
|
||||||
|
// Give it a moment, then kill
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
*child_lock = None;
|
||||||
|
*self.ready.lock().unwrap() = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_ready(&self) -> bool {
|
||||||
|
*self.ready.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_sidecar_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||||
|
// In dev mode, use the sidecar source directory
|
||||||
|
// In production, the built sidecar is bundled with the app
|
||||||
|
let resource_dir = app
|
||||||
|
.path()
|
||||||
|
.resource_dir()
|
||||||
|
.map_err(|e| format!("Failed to get resource dir: {e}"))?;
|
||||||
|
|
||||||
|
let prod_path = resource_dir.join("sidecar").join("dist").join("agent-runner.mjs");
|
||||||
|
if prod_path.exists() {
|
||||||
|
return Ok(prod_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dev fallback: look relative to the Cargo project root
|
||||||
|
let dev_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.join("sidecar")
|
||||||
|
.join("dist")
|
||||||
|
.join("agent-runner.mjs");
|
||||||
|
|
||||||
|
if dev_path.exists() {
|
||||||
|
return Ok(dev_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"Sidecar not found at {} or {}",
|
||||||
|
prod_path.display(),
|
||||||
|
dev_path.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SidecarManager {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue