winx_code_agent/server/
sessions.rs1use std::collections::HashMap;
2use std::sync::atomic::{AtomicUsize, Ordering};
3use std::sync::Arc;
4use std::time::Instant;
5
6use rmcp::service::{NotificationContext, RoleServer};
7use tokio::sync::Mutex;
8use tracing::{info, warn};
9
10use super::{SharedBashState, WinxService};
11use crate::runtime::{EmbeddedShellRuntime, ShellRuntime};
12use crate::state::bash_state::generate_thread_id;
13use crate::state::task_state::TaskRegistry;
14use crate::types::{Initialize, InitializeType, ModeName};
15
16pub(super) const MAX_SESSIONS: usize = 32;
19
20#[derive(Default)]
25pub(super) struct SessionRegistry {
26 pub(super) slots: HashMap<String, SharedBashState>,
27 pub(super) last_used: HashMap<String, Instant>,
29 pub(super) in_flight: HashMap<String, SessionPin>,
32 pub(super) last_active: Option<String>,
35}
36
37#[derive(Clone)]
42pub(super) struct SessionPin {
43 count: Arc<AtomicUsize>,
44}
45
46impl Default for SessionPin {
47 fn default() -> Self {
48 Self { count: Arc::new(AtomicUsize::new(0)) }
49 }
50}
51
52impl SessionPin {
53 fn acquire(&self) -> SessionGuard {
54 self.count.fetch_add(1, Ordering::SeqCst);
55 SessionGuard { count: self.count.clone() }
56 }
57
58 fn is_pinned(&self) -> bool {
59 self.count.load(Ordering::SeqCst) > 0
60 }
61}
62
63pub(super) struct SessionGuard {
66 count: Arc<AtomicUsize>,
67}
68
69impl Drop for SessionGuard {
70 fn drop(&mut self) {
71 self.count.fetch_sub(1, Ordering::SeqCst);
72 }
73}
74
75#[derive(Clone, Copy, PartialEq, Eq, Debug)]
77pub enum SessionIsolation {
78 Lenient,
81 Strict,
89}
90
91impl Default for WinxService {
92 fn default() -> Self {
93 Self::new()
94 }
95}
96
97impl WinxService {
98 pub fn new() -> Self {
100 Self::with_isolation(SessionIsolation::Lenient)
101 }
102
103 pub fn with_isolation(isolation: SessionIsolation) -> Self {
106 Self::with_runtime(isolation, Arc::new(EmbeddedShellRuntime))
107 }
108
109 pub fn with_runtime(isolation: SessionIsolation, shell_runtime: Arc<dyn ShellRuntime>) -> Self {
111 info!(?isolation, "Creating new WinxService instance");
112 Self {
113 sessions: Arc::new(Mutex::new(SessionRegistry::default())),
114 tasks: Arc::new(Mutex::new(TaskRegistry::default())),
115 root_bootstrap: Arc::new(Mutex::new(())),
116 shell_runtime,
117 version: env!("CARGO_PKG_VERSION").to_string(),
118 isolation,
119 }
120 }
121
122 pub(super) async fn session_for(&self, thread_id: &str) -> (SharedBashState, SessionGuard) {
126 let thread_id = crate::types::normalize_thread_id(thread_id);
127 let mut registry = self.sessions.lock().await;
128 let key = if thread_id.is_empty() {
129 match self.isolation {
130 SessionIsolation::Lenient => {
131 registry.last_active.clone().unwrap_or_else(|| "default".to_string())
132 }
133 SessionIsolation::Strict => "anonymous".to_string(),
134 }
135 } else {
136 thread_id.clone()
137 };
138
139 if !registry.slots.contains_key(&key) && registry.slots.len() >= MAX_SESSIONS {
140 let victim = registry
141 .last_used
142 .iter()
143 .filter(|(candidate, _)| **candidate != key)
144 .filter(|(candidate, _)| {
145 registry.in_flight.get(candidate.as_str()).is_none_or(|pin| !pin.is_pinned())
146 })
147 .min_by_key(|(_, last_used)| **last_used)
148 .map(|(candidate, _)| candidate.clone());
149
150 if let Some(victim) = victim {
151 if let Err(error) = self.shell_runtime.terminate_session(&victim).await {
155 warn!(%error, "failed to terminate LRU shell session '{victim}'");
156 }
157 registry.slots.remove(&victim);
158 registry.last_used.remove(&victim);
159 registry.in_flight.remove(&victim);
160 if registry.last_active.as_deref() == Some(victim.as_str()) {
161 registry.last_active = None;
162 }
163 warn!("Evicted LRU shell session '{victim}' (session cap {MAX_SESSIONS})");
164 } else {
165 warn!(
166 "All {MAX_SESSIONS} sessions busy; exceeding the cap rather than evicting an in-flight session"
167 );
168 }
169 }
170
171 let slot =
172 registry.slots.entry(key.clone()).or_insert_with(|| Arc::new(Mutex::new(None))).clone();
173 let pin = registry.in_flight.entry(key.clone()).or_default().clone();
174 registry.last_used.insert(key.clone(), Instant::now());
175 if !thread_id.is_empty() {
176 registry.last_active = Some(key);
177 }
178 (slot, pin.acquire())
179 }
180
181 pub(super) async fn active_slot(
184 &self,
185 session_prefix: Option<&str>,
186 ) -> Option<SharedBashState> {
187 let registry = self.sessions.lock().await;
188 if let Some(prefix) = session_prefix {
189 return registry
190 .last_used
191 .iter()
192 .filter(|(key, _)| key.starts_with(prefix))
193 .max_by_key(|(_, last_used)| **last_used)
194 .and_then(|(key, _)| registry.slots.get(key).cloned());
195 }
196 registry.last_active.as_ref().and_then(|key| registry.slots.get(key).cloned())
197 }
198
199 pub(super) async fn has_initialized_session(&self) -> bool {
200 let slots = {
201 let registry = self.sessions.lock().await;
202 registry.slots.values().cloned().collect::<Vec<_>>()
203 };
204 for slot in slots {
205 if slot.lock().await.as_ref().is_some_and(|state| state.initialized) {
206 return true;
207 }
208 }
209 false
210 }
211
212 #[allow(deprecated)]
216 pub(super) async fn initialize_from_client_roots(
217 &self,
218 context: NotificationContext<RoleServer>,
219 ) {
220 if self.isolation != SessionIsolation::Lenient {
221 return;
222 }
223 let _bootstrap_guard = self.root_bootstrap.lock().await;
224 if self.has_initialized_session().await {
225 return;
226 }
227 let supports_roots =
228 context.peer.peer_info().is_some_and(|info| info.capabilities.roots.is_some());
229 if !supports_roots {
230 return;
231 }
232 let roots = match context.peer.list_roots().await {
233 Ok(result) => result.roots,
234 Err(error) => {
235 warn!(%error, "client advertised Roots but roots/list failed");
236 return;
237 }
238 };
239 let cwd = std::env::current_dir().ok();
240 let mut paths =
241 roots.iter().filter_map(|root| root_uri_to_path(&root.uri)).collect::<Vec<_>>();
242 let selected = cwd
243 .as_ref()
244 .and_then(|cwd| paths.iter().position(|path| cwd.starts_with(path)))
245 .map(|index| paths.remove(index))
246 .or_else(|| paths.into_iter().next());
247 let Some(workspace) = selected else {
248 warn!("client returned no usable local file Roots; Initialize remains required");
249 return;
250 };
251
252 let initialize = Initialize {
253 init_type: InitializeType::FirstCall,
254 any_workspace_path: workspace.to_string_lossy().into_owned(),
255 initial_files_to_read: Vec::new(),
256 task_id_to_resume: String::new(),
257 mode_name: ModeName::Wcgw,
258 thread_id: generate_thread_id(),
259 code_writer_config: None,
260 };
261 let arguments = match serde_json::to_value(initialize) {
262 Ok(arguments) => arguments,
263 Err(error) => {
264 warn!(%error, "failed to serialize Roots bootstrap request");
265 return;
266 }
267 };
268 match self.handle_initialize(Some(arguments)).await {
269 Ok(_) => info!(workspace = %workspace.display(), "initialized Winx from MCP Roots"),
270 Err(error) => warn!(%error.message, "failed to initialize Winx from MCP Roots"),
271 }
272 }
273}
274
275pub(super) fn root_uri_to_path(uri: &str) -> Option<std::path::PathBuf> {
276 let encoded = uri.strip_prefix("file://")?;
277 let encoded = if let Some(path) = encoded.strip_prefix("localhost/") {
278 format!("/{path}")
279 } else if encoded.starts_with('/') {
280 encoded.to_string()
281 } else {
282 return None;
284 };
285 let decoded = percent_encoding::percent_decode_str(&encoded).decode_utf8().ok()?;
286 let path = std::path::PathBuf::from(decoded.as_ref());
287 path.is_absolute().then_some(path)
288}