Skip to main content

netget/cli/
client_startup.rs

1//! Client startup logic for TUI mode
2//!
3//! Handles connecting clients based on application state
4
5use anyhow::Result;
6use std::sync::Arc;
7use tokio::sync::mpsc;
8
9use crate::events::ActionExecutionError;
10use crate::llm::OllamaClient;
11use crate::state::app_state::AppState;
12use crate::state::ClientId;
13
14/// Resolve a caller-supplied client protocol name to the registry's canonical
15/// key, accepting any casing (and the registry's keyword aliases).
16///
17/// `CLIENT_REGISTRY` is keyed by each protocol's own spelling — "TCP", "Redis",
18/// "HTTP" — so a plain `get("tcp")` misses. This mirrors what
19/// `server_startup::start_server_from_action` gets from
20/// `ServerRegistry::parse_from_str`.
21pub fn resolve_client_protocol(protocol: &str) -> Option<String> {
22    let registry = &crate::protocol::CLIENT_REGISTRY;
23    if registry.get(protocol).is_some() {
24        return Some(protocol.to_string());
25    }
26    let upper = protocol.to_uppercase();
27    if registry.get(&upper).is_some() {
28        return Some(upper);
29    }
30    let lower = protocol.to_lowercase();
31    if registry.get(&lower).is_some() {
32        return Some(lower);
33    }
34    // Case-insensitive scan, then the registry's keyword parser as a last resort.
35    registry
36        .list_protocols()
37        .into_iter()
38        .find(|name| name.eq_ignore_ascii_case(protocol))
39        .or_else(|| registry.parse_from_str(protocol))
40}
41
42/// Build the error for a client protocol name that `resolve_client_protocol`
43/// could not map to a compiled-in protocol.
44///
45/// Defers to `ClientRegistry::resolve()`, which distinguishes "exists but is not
46/// compiled into this build (rebuild with --features X)" from "no such protocol"
47/// and offers a did-you-mean suggestion. `resolve_client_protocol` stays the
48/// lookup, because it also matches spellings `resolve()` does not; this only
49/// supplies the diagnostic once that lookup has already failed.
50fn unknown_client_protocol_error(protocol: &str) -> anyhow::Error {
51    match crate::protocol::CLIENT_REGISTRY.resolve(protocol) {
52        Err(e) => anyhow::anyhow!("{e}"),
53        // resolve() succeeded where resolve_client_protocol() did not; keep a
54        // sensible message rather than claiming success.
55        Ok(_) => anyhow::anyhow!("Unknown client protocol: {}", protocol),
56    }
57}
58
59/// Start a specific client by ID
60pub async fn start_client_by_id(
61    state: &AppState,
62    client_id: ClientId,
63    llm_client: &OllamaClient,
64    status_tx: &mpsc::UnboundedSender<String>,
65) -> Result<(), ActionExecutionError> {
66    // Get client info
67    let client = match state
68        .with_client_mut(client_id, |current| {
69            crate::protocol::StartupParams::preflight_owned(&mut current.startup_params)?;
70            Ok::<_, crate::protocol::StartupParamError>(current.clone())
71        })
72        .await
73    {
74        Some(Ok(current)) => current,
75        Some(Err(error)) => return Err(ActionExecutionError::Fatal(error.into())),
76        None => {
77            let _ = status_tx.send(format!("[ERROR] Client #{} not found", client_id.as_u32()));
78            return Ok(());
79        }
80    };
81
82    let protocol_name = client.protocol_name.clone();
83    let remote_addr = client.remote_addr.clone();
84
85    let msg = format!(
86        "[CLIENT] Starting client #{} ({}) connecting to {}",
87        client_id.as_u32(),
88        protocol_name,
89        remote_addr
90    );
91    let _ = status_tx.send(msg.clone());
92
93    // Actually connect the client using the registry
94    use crate::state::client::ClientStatus;
95
96    // Get protocol implementation from registry (accepting any casing)
97    let protocol = match resolve_client_protocol(&protocol_name)
98        .and_then(|name| crate::protocol::CLIENT_REGISTRY.get(&name))
99    {
100        Some(p) => p,
101        None => return Err(unknown_client_protocol_error(&protocol_name).into()),
102    };
103
104    // === --min-stability gate ===
105    // Refuse a protocol below the operator's maturity floor before connecting.
106    if let Some(min) = state.get_min_stability().await {
107        let actual = protocol.metadata().state;
108        if actual < min {
109            let msg = crate::cli::server_startup::min_stability_refusal(
110                "client",
111                &protocol_name,
112                actual,
113                min,
114            );
115            state
116                .update_client_status(client_id, ClientStatus::Error(msg.clone()))
117                .await;
118            let _ = status_tx.send(format!("[ERROR] {}", msg));
119            let _ = status_tx.send("__UPDATE_UI__".to_string());
120            return Err(ActionExecutionError::Fatal(anyhow::anyhow!(msg)));
121        }
122    }
123
124    // Runtime dependencies a start with these params needs (a binary on PATH, a system
125    // library). Only a dependency the probe established is absent refuses — see
126    // `dependencies::startup_blocker`, shared with server startup.
127    let system_caps = state.get_system_capabilities().await;
128    if let Some(dep) = crate::protocol::dependencies::startup_blocker(
129        &protocol.startup_dependencies(client.startup_params.as_ref()),
130        &system_caps,
131    ) {
132        let msg = format!(
133            "Cannot start {} client: {}. {}",
134            protocol_name,
135            dep.description(),
136            dep.installation_hint()
137        );
138        state
139            .update_client_status(client_id, ClientStatus::Error(msg.clone()))
140            .await;
141        let _ = status_tx.send(format!("[ERROR] {}", msg));
142        let _ = status_tx.send("__UPDATE_UI__".to_string());
143        return Err(ActionExecutionError::Fatal(anyhow::anyhow!(msg)));
144    }
145
146    // Build type-safe startup params if provided
147    //
148    // The JSON comes from the LLM or an MCP client, so a bad key must become a
149    // reported error (and an `Error` client status), never a panic.
150    let startup_params = if let Some(params_json) = client.startup_params.clone() {
151        // Get the parameter schema from the protocol
152        let schema = protocol.get_startup_parameters();
153        // Create validated StartupParams
154        match crate::protocol::StartupParams::new_validated(params_json, schema) {
155            Ok(p) => Some(p),
156            Err(e) => {
157                let msg = format!("Invalid startup_params for {}: {}", protocol_name, e);
158                state
159                    .update_client_status(client_id, ClientStatus::Error(msg.clone()))
160                    .await;
161                let _ = status_tx.send(format!("[ERROR] {}", msg));
162                let _ = status_tx.send("__UPDATE_UI__".to_string());
163                return Err(ActionExecutionError::Fatal(anyhow::anyhow!(msg)));
164            }
165        }
166    } else {
167        None
168    };
169
170    // Build connect context
171    let connect_ctx = crate::protocol::ConnectContext {
172        remote_addr: remote_addr.clone(),
173        llm_client: llm_client.clone(),
174        state: Arc::new(state.clone()),
175        status_tx: status_tx.clone(),
176        client_id,
177        startup_params,
178    };
179
180    // Connect the client using the protocol's connect method
181    match protocol.connect(connect_ctx).await {
182        Ok(local_addr) => {
183            // Update client status to connected
184            state
185                .update_client_status(client_id, ClientStatus::Connected)
186                .await;
187            let _ = status_tx.send(format!(
188                "[CLIENT] {} client #{} connected to {} (local: {})",
189                protocol_name,
190                client_id.as_u32(),
191                remote_addr,
192                local_addr
193            ));
194            let _ = status_tx.send("__UPDATE_UI__".to_string());
195        }
196        Err(e) => {
197            // For connection errors, set client status to error
198            state
199                .update_client_status(client_id, ClientStatus::Error(e.to_string()))
200                .await;
201            let _ = status_tx.send(format!(
202                "[ERROR] Failed to connect {} client #{} to {}: {}",
203                protocol_name,
204                client_id.as_u32(),
205                remote_addr,
206                e
207            ));
208            let _ = status_tx.send("__UPDATE_UI__".to_string());
209            return Err(ActionExecutionError::Fatal(e));
210        }
211    }
212
213    Ok(())
214}
215
216/// Start a client from action parameters (used by /load command)
217/// Returns the client ID on success
218#[allow(clippy::too_many_arguments)]
219pub async fn start_client_from_action(
220    state: &AppState,
221    protocol: &str,
222    remote_addr: &str,
223    instruction: String,
224    mut startup_params: Option<serde_json::Value>,
225    initial_memory: Option<String>,
226    event_handlers: Option<Vec<serde_json::Value>>,
227    scheduled_tasks: Option<Vec<crate::llm::actions::common::ServerTaskDefinition>>,
228    feedback_instructions: Option<String>,
229    llm_client: OllamaClient,
230    status_tx: Option<mpsc::UnboundedSender<String>>,
231) -> Result<ClientId> {
232    use crate::state::client::ClientStatus;
233    crate::protocol::StartupParams::preflight_owned(&mut startup_params)?;
234    let prepared_tasks = crate::state::task::prepare_tasks(
235        scheduled_tasks.as_deref(),
236        crate::state::task::TaskScope::Global,
237    )?;
238
239    // Get protocol from registry. Registry keys are the protocols' own casing
240    // ("TCP", "Redis", …), so resolve the caller's spelling to the canonical name
241    // first — the same normalization `start_server_from_action` does — and store
242    // that canonical name on the instance so later lookups by protocol_name work.
243    let protocol = match resolve_client_protocol(protocol) {
244        Some(name) => name,
245        None => return Err(unknown_client_protocol_error(protocol)),
246    };
247    let protocol_impl = match crate::protocol::CLIENT_REGISTRY.get(&protocol) {
248        Some(p) => p,
249        None => return Err(unknown_client_protocol_error(&protocol)),
250    };
251
252    // === --min-stability gate ===
253    // Refuse a protocol below the operator's maturity floor before building
254    // anything — no client instance exists yet, so there is nothing to strand.
255    if let Some(min) = state.get_min_stability().await {
256        let actual = protocol_impl.metadata().state;
257        if actual < min {
258            return Err(anyhow::anyhow!(
259                crate::cli::server_startup::min_stability_refusal("client", &protocol, actual, min)
260            ));
261        }
262    }
263
264    // === Runtime dependencies, BEFORE registering the client ===
265    //
266    // Same gate and same reasoning as `start_server_from_action`: refuse only a dependency the
267    // probe established is absent, name it with its install hint, and leave nothing stranded.
268    let system_caps = state.get_system_capabilities().await;
269    if let Some(dep) = crate::protocol::dependencies::startup_blocker(
270        &protocol_impl.startup_dependencies(startup_params.as_ref()),
271        &system_caps,
272    ) {
273        return Err(anyhow::anyhow!(
274            "Cannot start {} client: {}. {}",
275            protocol,
276            dep.description(),
277            dep.installation_hint()
278        ));
279    }
280
281    // === Validate startup params BEFORE registering the client ===
282    //
283    // This JSON is untrusted (LLM `open_client` / MCP `start_client`), so
284    // validating here means a malformed request errors out without leaving a
285    // `ClientInstance` stranded in `ClientStatus::Connecting`.
286    let startup_params_obj = match startup_params.clone() {
287        Some(params_json) => {
288            let schema = protocol_impl.get_startup_parameters();
289            Some(
290                crate::protocol::StartupParams::new_validated(params_json, schema).map_err(
291                    |e| anyhow::anyhow!("Invalid startup_params for {}: {}", protocol, e),
292                )?,
293            )
294        }
295        None => {
296            crate::protocol::StartupParams::new_validated(
297                serde_json::json!({}),
298                protocol_impl.get_startup_parameters(),
299            )?;
300            None
301        }
302    };
303
304    // === Parse event handlers BEFORE registering the client ===
305    //
306    // This used to be a lenient `filter_map(.. .ok())` *after* `add_client`,
307    // which silently dropped malformed handlers — the caller believed their
308    // script/static routing was in place and every event went to the LLM
309    // instead. Parse strictly (same validator as the server path) while there
310    // is still nothing to strand.
311    let event_handler_config = match event_handlers {
312        Some(handlers) if !handlers.is_empty() => Some(
313            crate::events::handler::EventHandler::parse_event_handlers(handlers)
314                .map_err(|e| anyhow::anyhow!("Invalid event_handlers for {}: {}", protocol, e))?,
315        ),
316        _ => None,
317    };
318
319    // Create client instance
320    let client = crate::state::client::ClientInstance {
321        id: ClientId::new(0), // Will be assigned by add_client
322        remote_addr: remote_addr.to_string(),
323        protocol_name: protocol.clone(),
324        instruction: instruction.clone(),
325        memory: String::new(),
326        status: ClientStatus::Connecting,
327        connection: None,
328        created_at: crate::utils::clock::Instant::now(),
329        status_changed_at: crate::utils::clock::Instant::now(),
330        startup_params: startup_params.clone(),
331        event_handler_config,
332        protocol_data: serde_json::Value::Null,
333        log_files: Default::default(),
334        feedback_instructions,
335        feedback_buffer: Vec::new(),
336        last_feedback_processed: None,
337        connection_history: Default::default(),
338    };
339
340    let client_id = state.add_client(client).await;
341    let mut startup_guard = super::startup_guard::StartupGuard::client(state, client_id);
342
343    // Set initial memory if provided
344    if let Some(mem) = initial_memory {
345        state
346            .with_client_mut(client_id, |c| {
347                c.memory = mem;
348            })
349            .await;
350    }
351
352    // Definitions were prepared before the owner was registered.
353    for mut task in prepared_tasks {
354        task.scope = crate::state::task::TaskScope::Client(client_id);
355        state.add_task(task).await;
356    }
357
358    // `startup_params_obj` was built and validated above, before `add_client`.
359
360    // Use the caller's status channel when one exists (the dashboard/TUI wants
361    // the client's lifecycle lines). Headless callers (the /load command, the
362    // open_client action, the MCP start_client tool) pass `None`; dropping the
363    // receiver would make every `status_tx.send()` in the client's connection
364    // loop fail silently, so drain a fresh channel to tracing instead.
365    let status_tx = match status_tx {
366        Some(tx) => tx,
367        None => {
368            let (status_tx, mut status_rx) = mpsc::unbounded_channel::<String>();
369            tokio::spawn(async move {
370                while let Some(msg) = status_rx.recv().await {
371                    if msg == "__UPDATE_UI__" {
372                        continue;
373                    }
374                    tracing::info!("{}", msg);
375                }
376            });
377            status_tx
378        }
379    };
380
381    // Build connect context
382    let connect_ctx = crate::protocol::ConnectContext {
383        remote_addr: remote_addr.to_string(),
384        llm_client: llm_client.clone(),
385        state: Arc::new(state.clone()),
386        status_tx,
387        client_id,
388        startup_params: startup_params_obj,
389    };
390
391    // Connect the client
392    match protocol_impl.connect(connect_ctx).await {
393        Ok(_local_addr) => {
394            state
395                .update_client_status(client_id, ClientStatus::Connected)
396                .await;
397            startup_guard.disarm();
398            Ok(client_id)
399        }
400        Err(e) => {
401            state
402                .update_client_status(client_id, ClientStatus::Error(e.to_string()))
403                .await;
404            state.remove_client(client_id).await;
405            startup_guard.disarm();
406            Err(e)
407        }
408    }
409}