Skip to main content

netget/cli/
management.rs

1//! Management surface for creating and updating servers and clients.
2//!
3//! This module is the single source of truth for the *shape* of a server or
4//! client instance — every field the `open_server` / `open_client` actions and
5//! the MCP `start_server` / `start_client` tools accept — and for the two
6//! operations that surface takes: **create** (spawn a brand new instance) and
7//! **update** (mutate a running one, in place where possible, with a clean
8//! stop+start where a change genuinely requires rebinding).
9//!
10//! Design rules (see the root `CLAUDE.md`):
11//!
12//! - **No per-protocol logic here.** Create routes through
13//!   `server_startup::start_server_from_action` /
14//!   `client_startup::start_client_from_action`; update reuses those same
15//!   executors for the restart path. There is no per-protocol match statement.
16//! - **Validate before mutating.** Startup parameters are validated against the
17//!   protocol's `get_startup_parameters()` *before* the running instance is
18//!   touched, so a bad update names the offending key (via `StartupParamError`,
19//!   propagated with `?`) and leaves the old instance running.
20//! - **Storage-free.** This is orchestration; it persists nothing itself.
21
22use anyhow::Result;
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25use tokio::sync::mpsc;
26
27use crate::llm::actions::common::ServerTaskDefinition;
28use crate::llm::actions::ParameterDefinition;
29use crate::llm::OllamaClient;
30use crate::state::app_state::AppState;
31use crate::state::{ClientId, ServerId};
32
33/// The management "form": everything a caller can specify about a server
34/// instance. Optional fields mean "unspecified" — on **create** an unspecified
35/// field falls back to its protocol default, on **update** an unspecified field
36/// is left unchanged.
37///
38/// This is the struct both create and update consume, so the two paths can
39/// never drift apart on which fields exist.
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
41pub struct ServerForm {
42    /// Protocol name (e.g. "http", "tcp", "dns"). Required to create; on update
43    /// it is immutable — supplying a different protocol is an error.
44    pub protocol: String,
45    /// Port to bind (socket protocols). `Some(0)` asks the OS to assign one.
46    pub port: Option<u16>,
47    /// Host/IP to bind (socket protocols). Defaults to the protocol default.
48    pub host: Option<String>,
49    /// Interface to bind (layer-2 / raw protocols such as arp, datalink, icmp).
50    pub interface: Option<String>,
51    /// Source MAC (layer-2 protocols), only meaningful with `interface`.
52    pub mac_address: Option<String>,
53    /// Speak first on connect (FTP/SMTP-style greeting). Only honoured by
54    /// protocols that declare a `send_first` startup parameter.
55    #[serde(default)]
56    pub send_first: bool,
57    /// Natural-language LLM instruction (the fallback handling path).
58    pub instruction: Option<String>,
59    /// Seed the instance's LLM memory.
60    pub initial_memory: Option<String>,
61    /// Protocol-specific startup parameters (validated against the schema).
62    pub startup_params: Option<Value>,
63    /// Deterministic event handlers (script / static / llm), matched in order.
64    pub event_handlers: Option<Vec<Value>>,
65    /// Scheduled LLM tasks scoped to this server.
66    pub scheduled_tasks: Option<Vec<ServerTaskDefinition>>,
67    /// Instructions for the automatic feedback loop.
68    pub feedback_instructions: Option<String>,
69}
70
71/// The management form for a client instance. Mirrors [`ServerForm`] against the
72/// `open_client` / `start_client` surface.
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74pub struct ClientForm {
75    /// Protocol name (e.g. "redis", "http"). Required to create; immutable on
76    /// update.
77    pub protocol: String,
78    /// Remote server address, "host:port". Required to create; changing it on
79    /// update forces a reconnect (restart).
80    pub remote_addr: Option<String>,
81    /// Natural-language LLM instruction (the fallback handling path).
82    pub instruction: Option<String>,
83    /// Seed the client's LLM memory.
84    pub initial_memory: Option<String>,
85    /// Protocol-specific startup parameters (validated against the schema).
86    pub startup_params: Option<Value>,
87    /// Deterministic event handlers, matched in order.
88    pub event_handlers: Option<Vec<Value>>,
89    /// Scheduled LLM tasks scoped to this client.
90    pub scheduled_tasks: Option<Vec<ServerTaskDefinition>>,
91    /// Instructions for the automatic feedback loop.
92    pub feedback_instructions: Option<String>,
93}
94
95/// Outcome of an update: which instance it now is, and whether applying it
96/// required a stop+start (which drops live connections) rather than an in-place
97/// change.
98#[derive(Debug, Clone)]
99pub struct UpdateOutcome {
100    /// The instance id after the update. Equal to the input id for a hot update;
101    /// a **new** id when a restart was required (stop+start allocates a fresh id).
102    pub id: u32,
103    /// True when the change forced a clean stop+start.
104    pub restarted: bool,
105    /// Human-readable summary of what changed, for the TUI / MCP / status stream.
106    pub summary: String,
107}
108
109/// Declared startup parameters for a server protocol, or `None` if the protocol
110/// is not compiled into this build. Used by the TUI create form to show what a
111/// chosen protocol accepts.
112pub fn server_declared_params(protocol: &str) -> Option<Vec<ParameterDefinition>> {
113    crate::protocol::server_registry::registry()
114        .resolve(protocol)
115        .ok()
116        .map(|p| p.get_startup_parameters())
117}
118
119/// Declared startup parameters for a client protocol, or `None` if it is not
120/// compiled into this build.
121pub fn client_declared_params(protocol: &str) -> Option<Vec<ParameterDefinition>> {
122    crate::protocol::CLIENT_REGISTRY
123        .resolve(protocol)
124        .ok()
125        .map(|p| p.get_startup_parameters())
126}
127
128/// Validate declared keys, required fields and JSON types against the schema,
129/// *before* any running instance is touched.
130///
131/// Delegates to the built-in [`StartupParams::new_validated`] check — the same validation
132/// `start_server_from_action` applies at spawn — so an undeclared key is reported
133/// (naming the key, via `StartupParamError`) and the running instance is left
134/// alone. Running it here, before `remove_server`, is what preserves the old
135/// instance when a restart-triggering `startup_params` update is bad.
136fn validate_params_declared(
137    params: &Value,
138    schema: &[ParameterDefinition],
139    protocol: &str,
140) -> Result<()> {
141    crate::protocol::StartupParams::preflight(params)?;
142    crate::protocol::spawn_context::StartupParams::new_validated(params.clone(), schema.to_vec())
143        .map(|_| ())
144        .map_err(|e| anyhow::anyhow!("Invalid startup_params for {}: {}", protocol, e))
145}
146
147/// Preflight shared TLS material while the current listener is still serving.
148/// File access/certificate parsing can fail independently of JSON shape.
149pub(crate) fn validate_server_startup_params(
150    params: &Value,
151    schema: &[ParameterDefinition],
152    protocol: &str,
153) -> Result<()> {
154    validate_params_declared(params, schema, protocol)?;
155    {
156        let names: std::collections::HashSet<_> = schema.iter().map(|p| p.name.as_str()).collect();
157        if [
158            "cert_path",
159            "key_path",
160            "common_name",
161            "san_dns_names",
162            "validity_days",
163            "organization",
164            "organizational_unit",
165        ]
166        .iter()
167        .all(|name| names.contains(name))
168        {
169            let validated =
170                crate::protocol::StartupParams::new_validated(params.clone(), schema.to_vec())?;
171            if names.contains("tls_enabled") {
172                crate::server::tls_cert_manager::extract_tls_config_from_params(&validated)?;
173            } else {
174                crate::server::tls_cert_manager::extract_required_tls_config_from_params(
175                    &validated,
176                )?;
177            }
178        }
179    }
180    Ok(())
181}
182
183impl ServerForm {
184    /// Create a brand new server from this form. Thin wrapper over the shared
185    /// `start_server_from_action` executor — no logic is duplicated.
186    pub async fn create(
187        mut self,
188        state: &AppState,
189        status_tx: mpsc::UnboundedSender<String>,
190    ) -> Result<ServerId> {
191        crate::protocol::StartupParams::preflight_owned(&mut self.startup_params)?;
192        let instruction = self.instruction.clone().unwrap_or_else(|| {
193            format!(
194                "You are a {} server. Handle requests appropriately.",
195                self.protocol
196            )
197        });
198        crate::cli::server_startup::start_server_from_action(
199            state,
200            self.mac_address,
201            self.interface,
202            self.host,
203            self.port,
204            &self.protocol,
205            self.send_first,
206            self.initial_memory,
207            instruction,
208            self.startup_params,
209            self.event_handlers,
210            self.scheduled_tasks,
211            self.feedback_instructions,
212            status_tx,
213        )
214        .await
215    }
216}
217
218impl ClientForm {
219    /// Create a brand new client from this form. Thin wrapper over the shared
220    /// `start_client_from_action` executor.
221    pub async fn create(
222        mut self,
223        state: &AppState,
224        llm_client: OllamaClient,
225        status_tx: mpsc::UnboundedSender<String>,
226    ) -> Result<ClientId> {
227        crate::protocol::StartupParams::preflight_owned(&mut self.startup_params)?;
228        let remote_addr = self
229            .remote_addr
230            .clone()
231            .ok_or_else(|| anyhow::anyhow!("remote_addr is required to open a client"))?;
232        let instruction = self.instruction.clone().unwrap_or_else(|| {
233            format!(
234                "You are a {} client connected to {}. Handle responses appropriately.",
235                self.protocol, remote_addr
236            )
237        });
238        crate::cli::client_startup::start_client_from_action(
239            state,
240            &self.protocol,
241            &remote_addr,
242            instruction,
243            self.startup_params,
244            self.initial_memory,
245            self.event_handlers,
246            self.scheduled_tasks,
247            self.feedback_instructions,
248            llm_client,
249            Some(status_tx),
250        )
251        .await
252    }
253}
254
255/// Merge a partial `overlay` object into `base`, overlay keys winning. Returns
256/// the merged object. Used so a partial `startup_params` update keeps the params
257/// it does not mention.
258fn merge_params(base: Option<&Value>, overlay: &Value) -> Result<Value> {
259    // Budget the entire borrowed input batch before cloning either object.
260    if !crate::utils::json_budget::within_values_budget(
261        base.into_iter().chain(std::iter::once(overlay)),
262        crate::protocol::spawn_context::MAX_STARTUP_PARAM_BYTES,
263        crate::protocol::spawn_context::MAX_STARTUP_PARAM_NODES,
264        crate::protocol::spawn_context::MAX_STARTUP_PARAM_DEPTH,
265    ) {
266        return Err(anyhow::anyhow!(
267            "startup_params exceeds the merged JSON size, node or depth budget"
268        ));
269    }
270    let map = overlay
271        .as_object()
272        .ok_or_else(|| anyhow::anyhow!("startup_params must be a JSON object"))?;
273    let mut out = base
274        .and_then(|v| v.as_object().cloned())
275        .unwrap_or_default();
276    for (k, v) in map {
277        out.insert(k.clone(), v.clone());
278    }
279    Ok(Value::Object(out))
280}
281
282/// Fields on a [`ServerForm`] whose change requires a rebind (a clean stop+start
283/// rather than an in-place mutation).
284fn server_needs_restart(form: &ServerForm) -> bool {
285    form.port.is_some()
286        || form.host.is_some()
287        || form.interface.is_some()
288        || form.mac_address.is_some()
289        || form.startup_params.is_some()
290}
291
292/// Update a running server by id, applying only the fields the caller set.
293///
294/// - Unknown id → clean error, nothing mutated.
295/// - Protocol mismatch → error (protocol is immutable; recreate to change it).
296/// - `startup_params` / `event_handlers` are validated **before** the running
297///   instance is touched, so a bad update leaves the old instance working.
298/// - Hot fields (instruction, memory, event handlers, feedback instructions,
299///   scheduled tasks) are applied in place, preserving live connections.
300/// - Binding fields (port, host, interface, mac) or a `startup_params` change
301///   force a clean stop+start; this drops connections and is reported as such.
302pub async fn update_server(
303    state: &AppState,
304    server_id: ServerId,
305    mut form: ServerForm,
306    status_tx: mpsc::UnboundedSender<String>,
307) -> Result<UpdateOutcome> {
308    crate::protocol::StartupParams::preflight_owned(&mut form.startup_params)?;
309    let current = state
310        .with_server_mut(server_id, |current| {
311            crate::protocol::StartupParams::preflight_owned(&mut current.startup_params)?;
312            Ok::<_, crate::protocol::StartupParamError>(current.clone())
313        })
314        .await
315        .ok_or_else(|| anyhow::anyhow!("Server #{} not found", server_id.as_u32()))??;
316
317    // Protocol is immutable on update.
318    if !form.protocol.is_empty() && !form.protocol.eq_ignore_ascii_case(&current.protocol_name) {
319        return Err(anyhow::anyhow!(
320            "Cannot change protocol of server #{} from '{}' to '{}'. Stop it and open a new one instead.",
321            server_id.as_u32(),
322            current.protocol_name,
323            form.protocol
324        ));
325    }
326
327    // Look the protocol up in the registry (case-insensitive via `resolve`) so we
328    // can read its declared startup-parameter schema for validation.
329    let protocol_impl = crate::protocol::server_registry::registry()
330        .resolve(&current.protocol_name)
331        .map_err(|_| {
332            anyhow::anyhow!("Server protocol '{}' not available", current.protocol_name)
333        })?;
334
335    // === Validate BEFORE mutating ===
336    // Compute the merged startup params that would take effect, and validate the
337    // whole thing against the schema. A bad key is reported (naming the key) and
338    // the running server is left untouched.
339    let prepared_tasks = crate::state::task::prepare_tasks(
340        form.scheduled_tasks.as_deref(),
341        crate::state::task::TaskScope::Server(server_id),
342    )?;
343    let merged_params: Option<Value> = match &form.startup_params {
344        Some(overlay) => Some(merge_params(current.startup_params.as_ref(), overlay)?),
345        None => current.startup_params.clone(),
346    };
347    if let Some(params) = &merged_params {
348        let schema = protocol_impl.get_startup_parameters();
349        validate_server_startup_params(params, &schema, &current.protocol_name)?;
350    }
351
352    // Validate event handlers before mutating, too.
353    if let Some(handlers) = &form.event_handlers {
354        crate::events::handler::EventHandler::parse_event_handlers(handlers.clone())
355            .map_err(|e| anyhow::anyhow!("Invalid event_handlers: {}", e))?;
356    }
357
358    if server_needs_restart(&form) {
359        // === Restart path: clean stop + start with merged config ===
360        let mut changed = Vec::new();
361        if form.port.is_some() {
362            changed.push("port");
363        }
364        if form.host.is_some() {
365            changed.push("host");
366        }
367        if form.interface.is_some() {
368            changed.push("interface");
369        }
370        if form.mac_address.is_some() {
371            changed.push("mac_address");
372        }
373        if form.startup_params.is_some() {
374            changed.push("startup_params");
375        }
376
377        // Merge current config with the requested overrides.
378        let instruction = form
379            .instruction
380            .clone()
381            .unwrap_or(current.instruction.clone());
382        let initial_memory = match &form.initial_memory {
383            Some(m) => Some(m.clone()),
384            None if !current.memory.is_empty() => Some(current.memory.clone()),
385            None => None,
386        };
387        // Event handlers: use the override, else re-serialize the current config
388        // so a rebind does not silently drop deterministic handlers.
389        let event_handlers: Option<Vec<Value>> = match &form.event_handlers {
390            Some(h) => Some(h.clone()),
391            None => current.event_handler_config.as_ref().and_then(|cfg| {
392                serde_json::to_value(&cfg.handlers)
393                    .ok()
394                    .and_then(|v| v.as_array().cloned())
395            }),
396        };
397        let feedback_instructions = form
398            .feedback_instructions
399            .clone()
400            .or(current.feedback_instructions.clone());
401        let port = form.port.or(Some(current.port));
402
403        // Release the old socket and its tasks, then start fresh.
404        state.remove_server(server_id).await;
405        let _ = status_tx.send(format!(
406            "[SERVER] Restarting server #{} ({}) to apply {}",
407            server_id.as_u32(),
408            current.protocol_name,
409            changed.join(", ")
410        ));
411
412        let new_id = crate::cli::server_startup::start_server_from_action(
413            state,
414            form.mac_address,
415            form.interface,
416            form.host,
417            port,
418            &current.protocol_name,
419            form.send_first,
420            initial_memory,
421            instruction,
422            merged_params,
423            event_handlers,
424            form.scheduled_tasks,
425            feedback_instructions,
426            status_tx.clone(),
427        )
428        .await?;
429
430        let summary = format!(
431            "Server #{} restarted as #{} (changed: {}); live connections were dropped.",
432            server_id.as_u32(),
433            new_id.as_u32(),
434            changed.join(", ")
435        );
436        let _ = status_tx.send(format!("[SERVER] {}", summary));
437        let _ = status_tx.send("__UPDATE_UI__".to_string());
438        return Ok(UpdateOutcome {
439            id: new_id.as_u32(),
440            restarted: true,
441            summary,
442        });
443    }
444
445    // === Hot path: mutate in place, connections preserved ===
446    let mut changed = Vec::new();
447
448    if let Some(instruction) = &form.instruction {
449        state.set_instruction(server_id, instruction.clone()).await;
450        changed.push("instruction");
451    }
452    if let Some(memory) = &form.initial_memory {
453        state.set_memory(server_id, memory.clone()).await;
454        changed.push("memory");
455    }
456    if let Some(handlers) = &form.event_handlers {
457        // Already validated above.
458        let config = crate::events::handler::EventHandler::parse_event_handlers(handlers.clone())
459            .map_err(|e| anyhow::anyhow!("Invalid event_handlers: {}", e))?;
460        state
461            .with_server_mut(server_id, |s| {
462                s.event_handler_config = Some(config);
463            })
464            .await;
465        changed.push("event_handlers");
466    }
467    if let Some(fb) = &form.feedback_instructions {
468        state
469            .with_server_mut(server_id, |s| {
470                s.feedback_instructions = Some(fb.clone());
471            })
472            .await;
473        changed.push("feedback_instructions");
474    }
475    if let Some(tasks) = form.scheduled_tasks {
476        let n = tasks.len();
477        for task in prepared_tasks {
478            state.add_task(task).await;
479        }
480        if n > 0 {
481            changed.push("scheduled_tasks");
482        }
483    }
484
485    let summary = if changed.is_empty() {
486        format!("Server #{}: nothing to update.", server_id.as_u32())
487    } else {
488        format!(
489            "Server #{} updated in place ({}); connections preserved.",
490            server_id.as_u32(),
491            changed.join(", ")
492        )
493    };
494    let _ = status_tx.send(format!("[SERVER] {}", summary));
495    let _ = status_tx.send("__UPDATE_UI__".to_string());
496
497    Ok(UpdateOutcome {
498        id: server_id.as_u32(),
499        restarted: false,
500        summary,
501    })
502}
503
504/// Fields on a [`ClientForm`] whose change requires a reconnect.
505fn client_needs_restart(form: &ClientForm) -> bool {
506    form.remote_addr.is_some() || form.startup_params.is_some()
507}
508
509/// Update a running client by id. Mirror of [`update_server`]: unknown id and
510/// bad params error cleanly without touching the instance; a `remote_addr` or
511/// `startup_params` change reconnects, everything else is applied in place.
512pub async fn update_client(
513    state: &AppState,
514    client_id: ClientId,
515    mut form: ClientForm,
516    llm_client: OllamaClient,
517    status_tx: mpsc::UnboundedSender<String>,
518) -> Result<UpdateOutcome> {
519    crate::protocol::StartupParams::preflight_owned(&mut form.startup_params)?;
520    let current = state
521        .with_client_mut(client_id, |current| {
522            crate::protocol::StartupParams::preflight_owned(&mut current.startup_params)?;
523            Ok::<_, crate::protocol::StartupParamError>(current.clone())
524        })
525        .await
526        .ok_or_else(|| anyhow::anyhow!("Client #{} not found", client_id.as_u32()))??;
527
528    if !form.protocol.is_empty() && !form.protocol.eq_ignore_ascii_case(&current.protocol_name) {
529        return Err(anyhow::anyhow!(
530            "Cannot change protocol of client #{} from '{}' to '{}'. Stop it and open a new one instead.",
531            client_id.as_u32(),
532            current.protocol_name,
533            form.protocol
534        ));
535    }
536
537    // Look the client protocol up (case-insensitive via `resolve`) for its
538    // declared startup-parameter schema.
539    let protocol_impl = crate::protocol::CLIENT_REGISTRY
540        .resolve(&current.protocol_name)
541        .map_err(|_| {
542            anyhow::anyhow!("Client protocol '{}' not available", current.protocol_name)
543        })?;
544
545    // Validate merged params before mutating.
546    let prepared_tasks = crate::state::task::prepare_tasks(
547        form.scheduled_tasks.as_deref(),
548        crate::state::task::TaskScope::Client(client_id),
549    )?;
550    let merged_params: Option<Value> = match &form.startup_params {
551        Some(overlay) => Some(merge_params(current.startup_params.as_ref(), overlay)?),
552        None => current.startup_params.clone(),
553    };
554    if let Some(params) = &merged_params {
555        let schema = protocol_impl.get_startup_parameters();
556        validate_params_declared(params, &schema, &current.protocol_name)?;
557    }
558
559    // Validate event handlers before mutating (clients parse each handler value).
560    if let Some(handlers) = &form.event_handlers {
561        validate_client_handlers(handlers)?;
562    }
563
564    if client_needs_restart(&form) {
565        let mut changed = Vec::new();
566        if form.remote_addr.is_some() {
567            changed.push("remote_addr");
568        }
569        if form.startup_params.is_some() {
570            changed.push("startup_params");
571        }
572
573        let remote_addr = form
574            .remote_addr
575            .clone()
576            .unwrap_or(current.remote_addr.clone());
577        let instruction = form
578            .instruction
579            .clone()
580            .unwrap_or(current.instruction.clone());
581        let initial_memory = match &form.initial_memory {
582            Some(m) => Some(m.clone()),
583            None if !current.memory.is_empty() => Some(current.memory.clone()),
584            None => None,
585        };
586        let event_handlers: Option<Vec<Value>> = match &form.event_handlers {
587            Some(h) => Some(h.clone()),
588            None => current.event_handler_config.as_ref().and_then(|cfg| {
589                serde_json::to_value(&cfg.handlers)
590                    .ok()
591                    .and_then(|v| v.as_array().cloned())
592            }),
593        };
594        let feedback_instructions = form
595            .feedback_instructions
596            .clone()
597            .or(current.feedback_instructions.clone());
598
599        // Release the old client (aborts its tasks / socket) then reconnect.
600        state.remove_client(client_id).await;
601        let _ = status_tx.send(format!(
602            "[CLIENT] Reconnecting client #{} ({}) to apply {}",
603            client_id.as_u32(),
604            current.protocol_name,
605            changed.join(", ")
606        ));
607
608        let new_id = crate::cli::client_startup::start_client_from_action(
609            state,
610            &current.protocol_name,
611            &remote_addr,
612            instruction,
613            merged_params,
614            initial_memory,
615            event_handlers,
616            form.scheduled_tasks,
617            feedback_instructions,
618            llm_client,
619            Some(status_tx.clone()),
620        )
621        .await?;
622
623        let summary = format!(
624            "Client #{} reconnected as #{} (changed: {}).",
625            client_id.as_u32(),
626            new_id.as_u32(),
627            changed.join(", ")
628        );
629        let _ = status_tx.send(format!("[CLIENT] {}", summary));
630        let _ = status_tx.send("__UPDATE_UI__".to_string());
631        return Ok(UpdateOutcome {
632            id: new_id.as_u32(),
633            restarted: true,
634            summary,
635        });
636    }
637
638    // Hot path.
639    let mut changed = Vec::new();
640    if let Some(instruction) = &form.instruction {
641        state
642            .set_instruction_for_client(client_id, instruction.clone())
643            .await;
644        changed.push("instruction");
645    }
646    if let Some(memory) = &form.initial_memory {
647        state.set_memory_for_client(client_id, memory.clone()).await;
648        changed.push("memory");
649    }
650    if let Some(handlers) = &form.event_handlers {
651        let config = parse_client_handlers(handlers)?;
652        state
653            .with_client_mut(client_id, |c| {
654                c.event_handler_config = Some(config);
655            })
656            .await;
657        changed.push("event_handlers");
658    }
659    if let Some(fb) = &form.feedback_instructions {
660        state
661            .with_client_mut(client_id, |c| {
662                c.feedback_instructions = Some(fb.clone());
663            })
664            .await;
665        changed.push("feedback_instructions");
666    }
667    if let Some(tasks) = form.scheduled_tasks {
668        let n = tasks.len();
669        for task in prepared_tasks {
670            state.add_task(task).await;
671        }
672        if n > 0 {
673            changed.push("scheduled_tasks");
674        }
675    }
676
677    let summary = if changed.is_empty() {
678        format!("Client #{}: nothing to update.", client_id.as_u32())
679    } else {
680        format!(
681            "Client #{} updated in place ({}).",
682            client_id.as_u32(),
683            changed.join(", ")
684        )
685    };
686    let _ = status_tx.send(format!("[CLIENT] {}", summary));
687    let _ = status_tx.send("__UPDATE_UI__".to_string());
688
689    Ok(UpdateOutcome {
690        id: client_id.as_u32(),
691        restarted: false,
692        summary,
693    })
694}
695
696/// Validate client event handler JSON without mutating anything.
697fn validate_client_handlers(handlers: &[Value]) -> Result<()> {
698    parse_client_handlers(handlers).map(|_| ())
699}
700
701/// Parse client event handler JSON into an [`EventHandlerConfig`].
702///
703/// Clients deserialize each handler value directly (as `start_client_from_action`
704/// does); an entry that fails to parse is a hard error rather than silently
705/// dropped, so a bad update is reported instead of applied partially.
706fn parse_client_handlers(handlers: &[Value]) -> Result<crate::scripting::EventHandlerConfig> {
707    use crate::scripting::{EventHandler, EventHandlerConfig};
708    let mut parsed = Vec::with_capacity(handlers.len());
709    for (i, h) in handlers.iter().enumerate() {
710        let handler: EventHandler = serde_json::from_value(h.clone())
711            .map_err(|e| anyhow::anyhow!("Invalid event_handlers[{}]: {}", i, e))?;
712        parsed.push(handler);
713    }
714    Ok(EventHandlerConfig { handlers: parsed })
715}
716
717// ===========================================================================
718// Interactive form (TUI `/create` and `/edit`)
719// ===========================================================================
720//
721// The interactive create/update form walks one field at a time: the operator is
722// shown a field's name/type/required flag/help, the footer input is prefilled
723// with the field's default (create) or current value (update), they edit it, and
724// Enter advances to the next field. On the last field the collected values are
725// assembled into a [`ServerForm`]/[`ClientForm`] and handed to the existing
726// `.create()` / [`update_server`] / [`update_client`] executors — no create or
727// update logic is duplicated here, and startup params are validated by those
728// executors exactly as any other caller's would be.
729//
730// The field *model*, prefill, and the collected-values → form assembly all live
731// here (next to `ServerForm`/`ClientForm`, and unit-testable without a live TUI);
732// The UI owns only the keystroke plumbing that drives it.
733
734/// Which operation an [`InteractiveForm`] will perform on submit.
735#[derive(Debug, Clone, PartialEq, Eq)]
736pub enum FormTarget {
737    /// Create a new server of `protocol`.
738    CreateServer,
739    /// Create a new client of `protocol`.
740    CreateClient,
741    /// Update the running server with this unified id.
742    UpdateServer(u32),
743    /// Update the running client with this unified id.
744    UpdateClient(u32),
745}
746
747/// How a filled-in [`FormField`] maps onto a `ServerForm`/`ClientForm`.
748#[derive(Debug, Clone, PartialEq, Eq)]
749enum FieldTarget {
750    Port,
751    RemoteAddr,
752    Instruction,
753    Memory,
754    EventHandlers,
755    FeedbackInstructions,
756    /// A protocol-declared startup parameter, carrying its declared type hint so
757    /// the entered text can be coerced to the right JSON shape.
758    StartupParam {
759        type_hint: String,
760    },
761}
762
763/// One editable field in an interactive form.
764#[derive(Debug, Clone)]
765pub struct FormField {
766    /// Field name — a common field label (`port`, `instruction`, …) or a startup
767    /// parameter's declared key.
768    pub name: String,
769    /// Type hint shown to the operator (`u16`, `string`, `json`, the declared
770    /// param type, …).
771    pub type_label: String,
772    /// Whether the field is required (only startup params are ever required; the
773    /// common fields all fall back to protocol defaults / current values).
774    pub required: bool,
775    /// Human-readable help.
776    pub help: String,
777    /// Text the footer input is prefilled with when this field becomes active.
778    pub prefill: String,
779    /// The value the operator submitted (trimmed). `None` until submitted; an
780    /// empty string means "unspecified" — protocol default on create, unchanged
781    /// on update.
782    pub value: Option<String>,
783    target: FieldTarget,
784}
785
786/// An in-progress interactive create/update form.
787#[derive(Debug, Clone)]
788pub struct InteractiveForm {
789    /// What the form will do on submit.
790    pub target: FormTarget,
791    /// The protocol name (immutable — not itself an editable field).
792    pub protocol: String,
793    /// The ordered fields.
794    pub fields: Vec<FormField>,
795    /// Index of the field currently being edited.
796    pub index: usize,
797}
798
799/// Current server config used to prefill an update form. A plain snapshot so the
800/// form builder stays decoupled from the live state types (and unit-testable).
801#[derive(Debug, Clone, Default)]
802pub struct ServerPrefill {
803    pub instruction: String,
804    pub memory: String,
805    pub port: u16,
806    pub startup_params: Option<Value>,
807    pub event_handlers: Option<Vec<Value>>,
808    pub feedback_instructions: Option<String>,
809}
810
811impl ServerPrefill {
812    /// Snapshot a running server for an update form.
813    ///
814    /// `port` is the port the server is **actually bound to** — `local_addr`, falling back to
815    /// the requested `port` only when nothing has bound yet. Those two differ exactly when the
816    /// request was `port: 0`, and prefilling the literal `0` there is not cosmetic: an update
817    /// form is a snapshot the operator edits in place, so a form showing `0` invites
818    /// submitting `0`, which is a request to bind *a different* random port. The operator
819    /// changes one handler and the server moves out from under every client connected to it.
820    ///
821    /// Constructing this by hand is what let the three call sites drift: the dashboard tree
822    /// resolved the bound port, and the legacy TUI's edit form did not.
823    pub fn from_server(server: &crate::state::server::ServerInstance) -> Self {
824        Self {
825            instruction: server.instruction.clone(),
826            memory: server.memory.clone(),
827            port: server
828                .local_addr
829                .map(|a| a.port())
830                .filter(|p| *p != 0)
831                .unwrap_or(server.port),
832            startup_params: server.startup_params.clone(),
833            event_handlers: server.event_handler_config.as_ref().and_then(|c| {
834                serde_json::to_value(&c.handlers)
835                    .ok()
836                    .and_then(|v| v.as_array().cloned())
837            }),
838            feedback_instructions: server.feedback_instructions.clone(),
839        }
840    }
841}
842
843/// Current client config used to prefill an update form.
844#[derive(Debug, Clone, Default)]
845pub struct ClientPrefill {
846    pub remote_addr: String,
847    pub instruction: String,
848    pub memory: String,
849    pub startup_params: Option<Value>,
850    pub event_handlers: Option<Vec<Value>>,
851    pub feedback_instructions: Option<String>,
852}
853
854/// Render a startup-param value already present in a config back to the text an
855/// operator would edit: a JSON string yields its bare contents, everything else
856/// its compact JSON form.
857fn param_value_to_prefill(v: &Value) -> String {
858    match v {
859        Value::String(s) => s.clone(),
860        other => serde_json::to_string(other).unwrap_or_default(),
861    }
862}
863
864/// Turn a protocol's declared startup parameters into form fields, prefilling from
865/// `current` (an update's existing `startup_params`) when present, else from the
866/// declared example for a required param (so a required field is not left blank).
867fn startup_param_fields(schema: &[ParameterDefinition], current: Option<&Value>) -> Vec<FormField> {
868    let current_obj = current.and_then(|v| v.as_object());
869    schema
870        .iter()
871        .map(|p| {
872            let prefill = current_obj
873                .and_then(|o| o.get(&p.name))
874                .map(param_value_to_prefill)
875                .unwrap_or_default();
876            FormField {
877                name: p.name.clone(),
878                type_label: p.type_hint.clone(),
879                required: p.required,
880                help: p.description.clone(),
881                prefill,
882                value: None,
883                target: FieldTarget::StartupParam {
884                    type_hint: p.type_hint.clone(),
885                },
886            }
887        })
888        .collect()
889}
890
891fn field(name: &str, ty: &str, help: &str, prefill: String, target: FieldTarget) -> FormField {
892    FormField {
893        name: name.to_string(),
894        type_label: ty.to_string(),
895        required: false,
896        help: help.to_string(),
897        prefill,
898        value: None,
899        target,
900    }
901}
902
903/// Serialize existing event handlers to the compact JSON array text an operator
904/// edits. Empty / absent handlers prefill as empty.
905fn handlers_to_prefill(handlers: &Option<Vec<Value>>) -> String {
906    match handlers {
907        Some(h) if !h.is_empty() => serde_json::to_string(h).unwrap_or_default(),
908        _ => String::new(),
909    }
910}
911
912impl InteractiveForm {
913    /// Build a create form for a server protocol from its declared startup params.
914    pub fn create_server(protocol: &str, schema: &[ParameterDefinition]) -> Self {
915        let mut fields = common_server_fields(&ServerPrefill::default(), false);
916        fields.extend(startup_param_fields(schema, None));
917        Self {
918            target: FormTarget::CreateServer,
919            protocol: protocol.to_string(),
920            fields,
921            index: 0,
922        }
923    }
924
925    /// Build a create form for a client protocol.
926    pub fn create_client(protocol: &str, schema: &[ParameterDefinition]) -> Self {
927        let mut fields = common_client_fields(&ClientPrefill::default(), false);
928        fields.extend(startup_param_fields(schema, None));
929        Self {
930            target: FormTarget::CreateClient,
931            protocol: protocol.to_string(),
932            fields,
933            index: 0,
934        }
935    }
936
937    /// Build an update form for a running server, prefilled from its current
938    /// config plus the current values of any declared startup params.
939    pub fn update_server(
940        id: u32,
941        protocol: &str,
942        schema: &[ParameterDefinition],
943        current: &ServerPrefill,
944    ) -> Self {
945        let mut fields = common_server_fields(current, true);
946        fields.extend(startup_param_fields(
947            schema,
948            current.startup_params.as_ref(),
949        ));
950        Self {
951            target: FormTarget::UpdateServer(id),
952            protocol: protocol.to_string(),
953            fields,
954            index: 0,
955        }
956    }
957
958    /// Build an update form for a running client.
959    pub fn update_client(
960        id: u32,
961        protocol: &str,
962        schema: &[ParameterDefinition],
963        current: &ClientPrefill,
964    ) -> Self {
965        let mut fields = common_client_fields(current, true);
966        fields.extend(startup_param_fields(
967            schema,
968            current.startup_params.as_ref(),
969        ));
970        Self {
971            target: FormTarget::UpdateClient(id),
972            protocol: protocol.to_string(),
973            fields,
974            index: 0,
975        }
976    }
977
978    /// The field currently being edited, if the form is not yet complete.
979    pub fn current_field(&self) -> Option<&FormField> {
980        self.fields.get(self.index)
981    }
982
983    /// A one-line prompt describing the current field.
984    pub fn prompt(&self) -> String {
985        match self.current_field() {
986            None => "Form complete.".to_string(),
987            Some(f) => {
988                let req = if f.required { ", required" } else { "" };
989                let hint = if f.required {
990                    "  (required)"
991                } else if matches!(
992                    self.target,
993                    FormTarget::CreateServer | FormTarget::CreateClient
994                ) {
995                    "  (empty = protocol default)"
996                } else {
997                    "  (empty = keep current)"
998                };
999                format!(
1000                    "Field {}/{}: {} ({}{}) — {}{}",
1001                    self.index + 1,
1002                    self.fields.len(),
1003                    f.name,
1004                    f.type_label,
1005                    req,
1006                    f.help,
1007                    hint,
1008                )
1009            }
1010        }
1011    }
1012
1013    /// A short title for the whole form, e.g. `Create http server`.
1014    pub fn title(&self) -> String {
1015        match self.target {
1016            FormTarget::CreateServer => format!("Create {} server", self.protocol),
1017            FormTarget::CreateClient => format!("Create {} client", self.protocol),
1018            FormTarget::UpdateServer(id) => {
1019                format!("Update {} server #{}", self.protocol, id)
1020            }
1021            FormTarget::UpdateClient(id) => {
1022                format!("Update {} client #{}", self.protocol, id)
1023            }
1024        }
1025    }
1026
1027    /// The prefill text for the current field (what the input should show).
1028    pub fn current_prefill(&self) -> String {
1029        self.current_field()
1030            .map(|f| f.prefill.clone())
1031            .unwrap_or_default()
1032    }
1033
1034    /// Record `raw` as the current field's value (trimmed) and advance.
1035    pub fn submit_current(&mut self, raw: &str) {
1036        if let Some(f) = self.fields.get_mut(self.index) {
1037            f.value = Some(raw.trim().to_string());
1038        }
1039        self.index += 1;
1040    }
1041
1042    /// Directly set a field's value by name (used by tests and any non-sequential
1043    /// caller). Returns false if no such field.
1044    pub fn set_field_value(&mut self, name: &str, value: &str) -> bool {
1045        if let Some(f) = self.fields.iter_mut().find(|f| f.name == name) {
1046            f.value = Some(value.trim().to_string());
1047            true
1048        } else {
1049            false
1050        }
1051    }
1052
1053    /// True once every field has been submitted.
1054    pub fn is_complete(&self) -> bool {
1055        self.index >= self.fields.len()
1056    }
1057
1058    fn is_client(&self) -> bool {
1059        matches!(
1060            self.target,
1061            FormTarget::CreateClient | FormTarget::UpdateClient(_)
1062        )
1063    }
1064
1065    fn is_update(&self) -> bool {
1066        matches!(
1067            self.target,
1068            FormTarget::UpdateServer(_) | FormTarget::UpdateClient(_)
1069        )
1070    }
1071
1072    /// The value a field contributes to the built form, or `None` if it should be
1073    /// left out.
1074    ///
1075    /// - **Create**: a non-empty submitted value is used; empty means "use the
1076    ///   protocol default".
1077    /// - **Update**: a non-empty value that *differs from the prefill* is used;
1078    ///   an unchanged (equal to prefill) or empty value means "leave as-is". This
1079    ///   is what keeps an update that only touches, say, the instruction from
1080    ///   re-submitting the unchanged port and forcing a needless restart.
1081    fn effective_value<'a>(&self, f: &'a FormField) -> Option<&'a str> {
1082        let trimmed = f.value.as_deref().unwrap_or("").trim();
1083        if trimmed.is_empty() {
1084            return None;
1085        }
1086        if self.is_update() && trimmed == f.prefill.trim() {
1087            return None;
1088        }
1089        Some(trimmed)
1090    }
1091
1092    /// Assemble the collected field values into a [`ServerForm`]. Errors (naming
1093    /// the field) on an unparseable value; the executors do the schema validation.
1094    pub fn into_server_form(&self) -> Result<ServerForm> {
1095        if self.is_client() {
1096            anyhow::bail!("into_server_form called on a client form");
1097        }
1098        let mut form = ServerForm {
1099            protocol: self.protocol.clone(),
1100            ..Default::default()
1101        };
1102        let mut params = serde_json::Map::new();
1103        for f in &self.fields {
1104            let v = match self.effective_value(f) {
1105                Some(v) => v,
1106                None => continue, // unspecified (create) / unchanged (update)
1107            };
1108            apply_common_field(f, v, &mut params, |t, val| match t {
1109                FieldTarget::Port => {
1110                    form.port = Some(parse_port(val, &f.name)?);
1111                    Ok(())
1112                }
1113                FieldTarget::Instruction => {
1114                    form.instruction = Some(val.to_string());
1115                    Ok(())
1116                }
1117                FieldTarget::Memory => {
1118                    form.initial_memory = Some(val.to_string());
1119                    Ok(())
1120                }
1121                FieldTarget::EventHandlers => {
1122                    form.event_handlers = Some(parse_handlers(val)?);
1123                    Ok(())
1124                }
1125                FieldTarget::FeedbackInstructions => {
1126                    form.feedback_instructions = Some(val.to_string());
1127                    Ok(())
1128                }
1129                FieldTarget::RemoteAddr => Ok(()), // not a server field
1130                FieldTarget::StartupParam { .. } => unreachable!(),
1131            })?;
1132        }
1133        if !params.is_empty() {
1134            form.startup_params = Some(Value::Object(params));
1135        }
1136        Ok(form)
1137    }
1138
1139    /// Assemble the collected field values into a [`ClientForm`].
1140    pub fn into_client_form(&self) -> Result<ClientForm> {
1141        if !self.is_client() {
1142            anyhow::bail!("into_client_form called on a server form");
1143        }
1144        let mut form = ClientForm {
1145            protocol: self.protocol.clone(),
1146            ..Default::default()
1147        };
1148        let mut params = serde_json::Map::new();
1149        for f in &self.fields {
1150            let v = match self.effective_value(f) {
1151                Some(v) => v,
1152                None => continue,
1153            };
1154            apply_common_field(f, v, &mut params, |t, val| match t {
1155                FieldTarget::RemoteAddr => {
1156                    form.remote_addr = Some(val.to_string());
1157                    Ok(())
1158                }
1159                FieldTarget::Instruction => {
1160                    form.instruction = Some(val.to_string());
1161                    Ok(())
1162                }
1163                FieldTarget::Memory => {
1164                    form.initial_memory = Some(val.to_string());
1165                    Ok(())
1166                }
1167                FieldTarget::EventHandlers => {
1168                    form.event_handlers = Some(parse_handlers(val)?);
1169                    Ok(())
1170                }
1171                FieldTarget::FeedbackInstructions => {
1172                    form.feedback_instructions = Some(val.to_string());
1173                    Ok(())
1174                }
1175                FieldTarget::Port => Ok(()), // not a client field
1176                FieldTarget::StartupParam { .. } => unreachable!(),
1177            })?;
1178        }
1179        if !params.is_empty() {
1180            form.startup_params = Some(Value::Object(params));
1181        }
1182        Ok(form)
1183    }
1184}
1185
1186/// The common (non-startup-param) fields of a server form, prefilled from
1187/// `current` when `update` is true.
1188fn common_server_fields(current: &ServerPrefill, update: bool) -> Vec<FormField> {
1189    let (port, instr, mem, fb) = if update {
1190        (
1191            current.port.to_string(),
1192            current.instruction.clone(),
1193            current.memory.clone(),
1194            current.feedback_instructions.clone().unwrap_or_default(),
1195        )
1196    } else {
1197        ("0".to_string(), String::new(), String::new(), String::new())
1198    };
1199    vec![
1200        field(
1201            "port",
1202            "u16",
1203            "Port to bind (0 = OS-assigns a free port)",
1204            port,
1205            FieldTarget::Port,
1206        ),
1207        field(
1208            "instruction",
1209            "string",
1210            "Natural-language LLM instruction (fallback handling path)",
1211            instr,
1212            FieldTarget::Instruction,
1213        ),
1214        field(
1215            "initial_memory",
1216            "string",
1217            "Seed the instance's LLM memory",
1218            mem,
1219            FieldTarget::Memory,
1220        ),
1221        field(
1222            "event_handlers",
1223            "json array",
1224            "Deterministic handlers, e.g. [{\"event_pattern\":\"http_request\",\"handler\":{\"type\":\"static\",...}}]",
1225            if update { handlers_to_prefill(&current.event_handlers) } else { String::new() },
1226            FieldTarget::EventHandlers,
1227        ),
1228        field(
1229            "feedback_instructions",
1230            "string",
1231            "Instructions for the automatic feedback loop",
1232            fb,
1233            FieldTarget::FeedbackInstructions,
1234        ),
1235    ]
1236}
1237
1238/// The common fields of a client form.
1239fn common_client_fields(current: &ClientPrefill, update: bool) -> Vec<FormField> {
1240    let (instr, mem, fb) = if update {
1241        (
1242            current.instruction.clone(),
1243            current.memory.clone(),
1244            current.feedback_instructions.clone().unwrap_or_default(),
1245        )
1246    } else {
1247        (String::new(), String::new(), String::new())
1248    };
1249    let mut remote = field(
1250        "remote_addr",
1251        "string",
1252        "Remote server address, host:port",
1253        if update {
1254            current.remote_addr.clone()
1255        } else {
1256            String::new()
1257        },
1258        FieldTarget::RemoteAddr,
1259    );
1260    // remote_addr is the one required common field on create.
1261    remote.required = !update;
1262    vec![
1263        remote,
1264        field(
1265            "instruction",
1266            "string",
1267            "Natural-language LLM instruction (fallback handling path)",
1268            instr,
1269            FieldTarget::Instruction,
1270        ),
1271        field(
1272            "initial_memory",
1273            "string",
1274            "Seed the instance's LLM memory",
1275            mem,
1276            FieldTarget::Memory,
1277        ),
1278        field(
1279            "event_handlers",
1280            "json array",
1281            "Deterministic handlers (JSON array)",
1282            if update {
1283                handlers_to_prefill(&current.event_handlers)
1284            } else {
1285                String::new()
1286            },
1287            FieldTarget::EventHandlers,
1288        ),
1289        field(
1290            "feedback_instructions",
1291            "string",
1292            "Instructions for the automatic feedback loop",
1293            fb,
1294            FieldTarget::FeedbackInstructions,
1295        ),
1296    ]
1297}
1298
1299/// Dispatch a non-empty field value: startup params are coerced and inserted into
1300/// `params`; everything else is handed to `set_common` (which writes the matching
1301/// form field).
1302fn apply_common_field(
1303    f: &FormField,
1304    v: &str,
1305    params: &mut serde_json::Map<String, Value>,
1306    set_common: impl FnOnce(&FieldTarget, &str) -> Result<()>,
1307) -> Result<()> {
1308    if let FieldTarget::StartupParam { type_hint } = &f.target {
1309        params.insert(f.name.clone(), coerce_param_value(v, type_hint, &f.name)?);
1310        Ok(())
1311    } else {
1312        set_common(&f.target, v)
1313    }
1314}
1315
1316fn parse_port(v: &str, name: &str) -> Result<u16> {
1317    v.parse::<u16>().map_err(|_| {
1318        anyhow::anyhow!(
1319            "field '{}' must be a port number 0-65535, got '{}'",
1320            name,
1321            v
1322        )
1323    })
1324}
1325
1326fn parse_handlers(v: &str) -> Result<Vec<Value>> {
1327    let parsed: Value = serde_json::from_str(v)
1328        .map_err(|e| anyhow::anyhow!("field 'event_handlers' is not valid JSON: {}", e))?;
1329    parsed
1330        .as_array()
1331        .cloned()
1332        .ok_or_else(|| anyhow::anyhow!("field 'event_handlers' must be a JSON array"))
1333}
1334
1335/// Coerce entered text to a JSON value according to the declared type hint.
1336/// Numbers/booleans/objects/arrays are parsed; anything else stays a string. On a
1337/// declared numeric/boolean field that fails to parse, error (naming the field)
1338/// rather than silently shipping a string the schema will reject.
1339fn coerce_param_value(raw: &str, type_hint: &str, name: &str) -> Result<Value> {
1340    let hint = type_hint.to_lowercase();
1341    if hint.contains("bool") {
1342        return raw
1343            .parse::<bool>()
1344            .map(Value::Bool)
1345            .map_err(|_| anyhow::anyhow!("field '{}' must be true/false, got '{}'", name, raw));
1346    }
1347    if hint.contains("int")
1348        || hint.contains("number")
1349        || hint.contains("u16")
1350        || hint.contains("u32")
1351    {
1352        if let Ok(n) = raw.parse::<i64>() {
1353            return Ok(Value::from(n));
1354        }
1355        if let Ok(fl) = raw.parse::<f64>() {
1356            return Ok(Value::from(fl));
1357        }
1358        anyhow::bail!("field '{}' must be a number, got '{}'", name, raw);
1359    }
1360    if hint.contains("object") || hint.contains("array") || hint.contains("json") {
1361        return serde_json::from_str(raw)
1362            .map_err(|e| anyhow::anyhow!("field '{}' must be valid JSON: {}", name, e));
1363    }
1364    Ok(Value::String(raw.to_string()))
1365}