diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 4b148d022..29c2798ac 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -36,6 +36,7 @@ const overrides = new Map([ ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 650], ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], + ["src/shared/api/relayClientSession.ts", 725], // durable websocket session manager with reconnect/replay/recovery state ["src/shared/api/tauri.ts", 950], ]); diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs new file mode 100644 index 000000000..46514a072 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -0,0 +1,45 @@ +use tauri::{AppHandle, State}; + +use crate::{ + app_state::AppState, + managed_agents::{ + build_managed_agent_summary, find_managed_agent_mut, load_managed_agents, + save_managed_agents, sync_managed_agent_processes, ManagedAgentSummary, + }, + util::now_iso, +}; + +#[tauri::command] +pub fn set_managed_agent_start_on_app_launch( + pubkey: String, + start_on_app_launch: bool, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + + if sync_managed_agent_processes(&mut records, &mut runtimes) { + save_managed_agents(&app, &records)?; + } + + { + let record = find_managed_agent_mut(&mut records, &pubkey)?; + record.start_on_app_launch = start_on_app_launch; + record.updated_at = now_iso(); + } + + save_managed_agents(&app, &records)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + build_managed_agent_summary(&app, record, &runtimes) +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index fc31acb9f..2eb6910ff 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -224,6 +224,8 @@ pub async fn create_managed_agent( .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), + start_on_app_launch: input.start_on_app_launch, + runtime_pid: None, created_at: now_iso(), updated_at: now_iso(), last_started_at: None, diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index b80cf7eee..45d3889d0 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +mod agent_settings; mod agents; mod channels; mod identity; @@ -6,6 +7,7 @@ mod messages; mod profile; mod tokens; +pub use agent_settings::*; pub use agents::*; pub use channels::*; pub use identity::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a55b015e0..3ac8ed511 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -5,13 +5,86 @@ mod models; mod relay; mod util; -use app_state::build_app_state; +use app_state::{build_app_state, AppState}; use commands::*; +use managed_agents::{ + find_managed_agent_mut, load_managed_agents, save_managed_agents, start_managed_agent_process, + stop_managed_agent_process, sync_managed_agent_processes, +}; +use tauri::{Manager, RunEvent}; use tauri_plugin_window_state::StateFlags; +fn restore_managed_agents_on_launch(app: &tauri::AppHandle) -> Result<(), String> { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + let mut changed = sync_managed_agent_processes(&mut records, &mut runtimes); + let pubkeys_to_restore = records + .iter() + .filter(|record| record.start_on_app_launch) + .map(|record| record.pubkey.clone()) + .collect::>(); + + for pubkey in pubkeys_to_restore { + let record = find_managed_agent_mut(&mut records, &pubkey)?; + match start_managed_agent_process(app, record, &mut runtimes) { + Ok(()) => { + changed = true; + } + Err(error) => { + record.updated_at = util::now_iso(); + record.last_error = Some(error); + changed = true; + } + } + } + + if changed { + save_managed_agents(app, &records)?; + } + + Ok(()) +} + +fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + let mut changed = sync_managed_agent_processes(&mut records, &mut runtimes); + + for record in records.iter_mut() { + if record.runtime_pid.is_none() && !runtimes.contains_key(&record.pubkey) { + continue; + } + + stop_managed_agent_process(record, &mut runtimes)?; + changed = true; + } + + if changed { + save_managed_agents(app, &records)?; + } + + Ok(()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + let app = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin( tauri_plugin_window_state::Builder::default() @@ -21,6 +94,14 @@ pub fn run() { .plugin(tauri_plugin_websocket::init()) .plugin(tauri_plugin_dialog::init()) .manage(build_app_state()) + .setup(|app| { + let app_handle = app.handle().clone(); + if let Err(error) = restore_managed_agents_on_launch(&app_handle) { + eprintln!("sprout-desktop: failed to restore managed agents: {error}"); + } + + Ok(()) + }) .invoke_handler(tauri::generate_handler![ get_identity, get_profile, @@ -67,12 +148,21 @@ pub fn run() { create_managed_agent, start_managed_agent, stop_managed_agent, + set_managed_agent_start_on_app_launch, delete_managed_agent, mint_managed_agent_token, get_managed_agent_log, ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application"); + + app.run(|app_handle, event| { + if matches!(event, RunEvent::ExitRequested { .. }) { + if let Err(error) = shutdown_managed_agents(app_handle) { + eprintln!("sprout-desktop: failed to stop managed agents: {error}"); + } + } + }); } #[cfg(test)] diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 66bef77bc..ff110ea32 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, process::Command}; use tauri::AppHandle; @@ -11,6 +11,58 @@ use crate::{ util::now_iso, }; +#[cfg(unix)] +fn process_is_running(pid: u32) -> bool { + Command::new("kill") + .arg("-0") + .arg(pid.to_string()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +#[cfg(not(unix))] +fn process_is_running(_pid: u32) -> bool { + false +} + +#[cfg(unix)] +fn terminate_process(pid: u32) -> Result<(), String> { + let pid_arg = pid.to_string(); + let status = Command::new("kill") + .arg("-TERM") + .arg(&pid_arg) + .status() + .map_err(|error| format!("failed to terminate process {pid}: {error}"))?; + if !status.success() && process_is_running(pid) { + return Err(format!("failed to terminate process {pid}: signal was rejected")); + } + + for _ in 0..10 { + if !process_is_running(pid) { + return Ok(()); + } + + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + let kill_status = Command::new("kill") + .arg("-KILL") + .arg(&pid_arg) + .status() + .map_err(|error| format!("failed to kill process {pid}: {error}"))?; + if !kill_status.success() && process_is_running(pid) { + return Err(format!("failed to kill process {pid}: signal was rejected")); + } + + Ok(()) +} + +#[cfg(not(unix))] +fn terminate_process(_pid: u32) -> Result<(), String> { + Err("managed agent shutdown after app restart is only supported on Unix".to_string()) +} + pub fn sync_managed_agent_processes( records: &mut [ManagedAgentRecord], runtimes: &mut HashMap, @@ -38,6 +90,7 @@ pub fn sync_managed_agent_processes( if let Some(record) = records.iter_mut().find(|record| record.pubkey == *pubkey) { record.updated_at = now_iso(); + record.runtime_pid = None; record.last_stopped_at = Some(now_iso()); record.last_exit_code = status.code(); record.last_error = if status.success() { @@ -55,6 +108,27 @@ pub fn sync_managed_agent_processes( runtimes.remove(&pubkey); } + for record in records.iter_mut() { + if runtimes.contains_key(&record.pubkey) { + continue; + } + + let Some(pid) = record.runtime_pid else { + continue; + }; + + if process_is_running(pid) { + continue; + } + + record.runtime_pid = None; + record.updated_at = now_iso(); + if record.last_stopped_at.is_none() { + record.last_stopped_at = Some(now_iso()); + } + changed = true; + } + changed } @@ -63,12 +137,21 @@ pub fn build_managed_agent_summary( record: &ManagedAgentRecord, runtimes: &HashMap, ) -> Result { + let persisted_pid = record.runtime_pid.filter(|pid| process_is_running(*pid)); let (status, pid, log_path) = if let Some(runtime) = runtimes.get(&record.pubkey) { ( "running".to_string(), Some(runtime.child.id()), runtime.log_path.display().to_string(), ) + } else if let Some(pid) = persisted_pid { + ( + "running".to_string(), + Some(pid), + managed_agent_log_path(app, &record.pubkey)? + .display() + .to_string(), + ) } else { ( "stopped".to_string(), @@ -99,6 +182,7 @@ pub fn build_managed_agent_summary( last_stopped_at: record.last_stopped_at.clone(), last_exit_code: record.last_exit_code, last_error: record.last_error.clone(), + start_on_app_launch: record.start_on_app_launch, log_path, }) } @@ -131,6 +215,16 @@ pub fn start_managed_agent_process( runtimes.remove(&record.pubkey); } + if let Some(pid) = record.runtime_pid { + if process_is_running(pid) { + record.updated_at = now_iso(); + record.last_error = None; + return Ok(()); + } + + record.runtime_pid = None; + } + let log_path = managed_agent_log_path(app, &record.pubkey)?; append_log_marker( &log_path, @@ -198,6 +292,7 @@ pub fn start_managed_agent_process( let now = now_iso(); record.updated_at = now.clone(); + record.runtime_pid = Some(child.id()); record.last_started_at = Some(now); record.last_stopped_at = None; record.last_exit_code = None; @@ -215,6 +310,18 @@ pub fn stop_managed_agent_process( runtimes: &mut HashMap, ) -> Result<(), String> { let Some(mut runtime) = runtimes.remove(&record.pubkey) else { + if let Some(pid) = record.runtime_pid { + if process_is_running(pid) { + terminate_process(pid)?; + } + + let now = now_iso(); + record.runtime_pid = None; + record.updated_at = now.clone(); + record.last_stopped_at = Some(now); + record.last_exit_code = None; + record.last_error = None; + } return Ok(()); }; @@ -224,6 +331,7 @@ pub fn stop_managed_agent_process( .wait() .map_err(|error| format!("failed to wait for agent shutdown: {error}"))?; let now = now_iso(); + record.runtime_pid = None; record.updated_at = now.clone(); record.last_stopped_at = Some(now); record.last_exit_code = status.code(); diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index d7dea01b1..ca2b9ffeb 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -27,6 +27,10 @@ pub struct ManagedAgentRecord { #[serde(default = "default_agent_parallelism")] pub parallelism: u32, pub system_prompt: Option, + #[serde(default = "default_start_on_app_launch")] + pub start_on_app_launch: bool, + #[serde(default)] + pub runtime_pid: Option, pub created_at: String, pub updated_at: String, pub last_started_at: Option, @@ -62,6 +66,7 @@ pub struct ManagedAgentSummary { pub last_stopped_at: Option, pub last_exit_code: Option, pub last_error: Option, + pub start_on_app_launch: bool, pub log_path: String, } @@ -85,6 +90,8 @@ pub struct CreateManagedAgentRequest { pub token_name: Option, #[serde(default)] pub spawn_after_create: bool, + #[serde(default = "default_start_on_app_launch")] + pub start_on_app_launch: bool, } #[derive(Debug, Serialize)] @@ -167,3 +174,7 @@ pub const DEFAULT_AGENT_PARALLELISM: u32 = 1; fn default_agent_parallelism() -> u32 { DEFAULT_AGENT_PARALLELISM } + +fn default_start_on_app_launch() -> bool { + true +} diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 62d276676..c5c7d7327 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -18,6 +18,7 @@ import { startManagedAgent, stopManagedAgent, } from "@/shared/api/tauri"; +import { setManagedAgentStartOnAppLaunch } from "@/shared/api/tauriManagedAgents"; import type { CreateManagedAgentInput, ManagedAgent, @@ -155,6 +156,24 @@ export function useStopManagedAgentMutation() { }); } +export function useSetManagedAgentStartOnAppLaunchMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + pubkey, + startOnAppLaunch, + }: { + pubkey: string; + startOnAppLaunch: boolean; + }) => setManagedAgentStartOnAppLaunch(pubkey, startOnAppLaunch), + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); + await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); + }, + }); +} + export function useDeleteManagedAgentMutation() { const queryClient = useQueryClient(); diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 53d05922b..c18ae0795 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -7,6 +7,7 @@ import { useManagedAgentsQuery, useMintManagedAgentTokenMutation, useRelayAgentsQuery, + useSetManagedAgentStartOnAppLaunchMutation, useStartManagedAgentMutation, useStopManagedAgentMutation, } from "@/features/agents/hooks"; @@ -28,6 +29,7 @@ export function AgentsView() { const managedAgentsQuery = useManagedAgentsQuery(); const startMutation = useStartManagedAgentMutation(); const stopMutation = useStopManagedAgentMutation(); + const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation(); const deleteMutation = useDeleteManagedAgentMutation(); const mintTokenMutation = useMintManagedAgentTokenMutation(); const [isCreateOpen, setIsCreateOpen] = React.useState(false); @@ -124,6 +126,32 @@ export function AgentsView() { } } + async function handleToggleStartOnAppLaunch( + pubkey: string, + startOnAppLaunch: boolean, + ) { + setActionNoticeMessage(null); + setActionErrorMessage(null); + + try { + const updated = await startOnLaunchMutation.mutateAsync({ + pubkey, + startOnAppLaunch, + }); + setActionNoticeMessage( + updated.startOnAppLaunch + ? `Will start ${updated.name} automatically when the desktop app opens.` + : `${updated.name} will stay manual-start only.`, + ); + } catch (error) { + setActionErrorMessage( + error instanceof Error + ? error.message + : "Failed to update startup preference.", + ); + } + } + async function handleMintToken(pubkey: string, name: string) { setActionNoticeMessage(null); setActionErrorMessage(null); @@ -177,6 +205,7 @@ export function AgentsView() { const isActionPending = startMutation.isPending || stopMutation.isPending || + startOnLaunchMutation.isPending || deleteMutation.isPending || mintTokenMutation.isPending; @@ -218,6 +247,9 @@ export function AgentsView() { onStop={(pubkey) => { void handleStop(pubkey); }} + onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => { + void handleToggleStartOnAppLaunch(pubkey, startOnAppLaunch); + }} selectedAgentPubkey={selectedAgent?.pubkey ?? null} /> diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index 1f6bd00a5..a77a32fc3 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -48,6 +48,7 @@ export function CreateAgentDialog({ const [relayUrl, setRelayUrl] = React.useState(""); const [mintToken, setMintToken] = React.useState(true); const [spawnAfterCreate, setSpawnAfterCreate] = React.useState(true); + const [startOnAppLaunch, setStartOnAppLaunch] = React.useState(true); const [tokenName, setTokenName] = React.useState(""); const [selectedScopes, setSelectedScopes] = React.useState>( () => new Set(DEFAULT_MANAGED_AGENT_SCOPES), @@ -128,6 +129,7 @@ export function CreateAgentDialog({ setRelayUrl(""); setMintToken(true); setSpawnAfterCreate(true); + setStartOnAppLaunch(true); setTokenName(""); setSelectedScopes(new Set(DEFAULT_MANAGED_AGENT_SCOPES)); setAcpCommand("sprout-acp"); @@ -215,6 +217,7 @@ export function CreateAgentDialog({ tokenName: tokenName.trim() || undefined, tokenScopes: [...selectedScopes], spawnAfterCreate, + startOnAppLaunch, }; const created = await createMutation.mutateAsync(input); handleOpenChange(false); @@ -258,12 +261,16 @@ export function CreateAgentDialog({ setMintToken((current) => !current); } }} + onToggleStartOnAppLaunch={() => { + setStartOnAppLaunch((current) => !current); + }} onToggleSpawnAfterCreate={() => { if (!spawnToggleDisabled) { setSpawnAfterCreate((current) => !current); } }} prereqs={prereqs} + startOnAppLaunch={startOnAppLaunch} spawnAfterCreate={spawnAfterCreate} spawnToggleDisabled={spawnToggleDisabled} /> diff --git a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx index 3c1770569..9850c877b 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx @@ -261,9 +261,11 @@ export function CreateAgentOptionToggles({ mintToken, mintToggleDisabled, prereqs, + startOnAppLaunch, spawnAfterCreate, spawnToggleDisabled, onToggleMintToken, + onToggleStartOnAppLaunch, onToggleSpawnAfterCreate, }: { isMintSupported: boolean; @@ -271,13 +273,15 @@ export function CreateAgentOptionToggles({ mintToken: boolean; mintToggleDisabled: boolean; prereqs: ManagedAgentPrereqs | null; + startOnAppLaunch: boolean; spawnAfterCreate: boolean; spawnToggleDisabled: boolean; onToggleMintToken: () => void; + onToggleStartOnAppLaunch: () => void; onToggleSpawnAfterCreate: () => void; }) { return ( -
+
+ + + +