mecha10-cli 0.6.3

Mecha10 CLI tool
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! Dev session service
//!
//! Manages the complete lifecycle of a development mode session.
//! This service encapsulates all state and operations for a dev session,
//! providing a clean, high-level API for handlers.

use crate::commands::dev::DevArgs;
use crate::context::CliContext;
use crate::dev::lifecycle_adapter::CliLifecycleManager;
use crate::dev::node_selection::NodeToRun;
use crate::types::ProjectConfig;
use crate::ui::{dev_banners, LogBuffer};
use anyhow::{Context, Result};

use super::command_listener::{build_command_listener, CommandListenerConfig};
use super::ops;
use ops::NodeRegistry;

/// Development mode session
///
/// Encapsulates all state and operations for a development mode session.
/// This provides a stateful, high-level API that makes handlers extremely thin.
///
/// # Example
///
/// ```rust,ignore
/// let mut session = DevSession::initialize(ctx, args).await?;
/// session.setup_infrastructure(ctx).await?;
/// session.build_if_needed(ctx)?;
/// session.spawn_nodes(ctx)?;
/// session.run(ctx).await?;
/// ```
pub struct DevSession {
    /// Project name
    pub project_name: String,
    /// Project version
    #[allow(dead_code)]
    pub project_version: String,
    /// Project configuration
    pub config: ProjectConfig,
    /// Nodes to run
    pub nodes: Vec<NodeToRun>,
    /// Node PID/failure bookkeeping - see `NodeRegistry`'s module docs (LAB-1891) for why this
    /// needs to be a single owned struct rather than several independently-threaded collections.
    pub node_registry: NodeRegistry,
    /// Whether to build before running
    pub should_build: bool,
    /// Whether to run in watch mode
    pub watch_mode: bool,
    /// Whether specific nodes were requested on CLI (vs using lifecycle mode)
    pub cli_nodes_specified: bool,
    /// Lifecycle manager (if project has lifecycle config)
    pub lifecycle: Option<CliLifecycleManager>,
    /// Log buffer for TUI logs
    pub log_buffer: LogBuffer,
}

impl DevSession {
    /// Initialize a new dev session
    ///
    /// This loads project configuration, validates the project,
    /// selects nodes to run, and resolves simulation paths.
    ///
    /// # Arguments
    ///
    /// * `ctx` - CLI context
    /// * `args` - Dev command arguments
    ///
    /// # Returns
    ///
    /// Initialized dev session ready for setup
    pub async fn initialize(ctx: &mut CliContext, args: &DevArgs) -> Result<Self> {
        // Display header
        dev_banners::print_header();

        // Validate project
        let project_root = std::env::current_dir()?;
        crate::dev::validation::check_project_initialized(&project_root)?;

        // Regenerate `websocket-bridge`'s relay topic allowlist from other nodes' own configs
        // (LAB relay-topics-core-deriver) before anything gets spawned, so a locally running
        // `mecha10 dev` session's relay reflects the current derived allowlist.
        mecha10_core::relay_topics::regenerate_websocket_bridge_relay_config(&project_root)
            .context("Failed to regenerate websocket-bridge relay topics")?;

        // Load project info
        use crate::services::DevService;
        let (project_name, project_version, config) = DevService::load_project_info(ctx).await?;

        dev_banners::print_project_info(&project_name, &project_version);

        // Select nodes to run
        let nodes = crate::dev::node_selection::get_nodes_to_run(&args.nodes, &config);
        if !args.nodes.is_empty() {
            let available = crate::dev::node_selection::get_available_node_names(&config);
            crate::dev::validation::validate_node_names(&args.nodes, &available)?;
        }

        if nodes.is_empty() {
            dev_banners::print_no_nodes()?;
            return Err(anyhow::anyhow!("No nodes configured to run"));
        }

        dev_banners::print_nodes(&nodes);

        // Initialize lifecycle manager if project has lifecycle config.
        //
        // `mecha10 dev` requires a lifecycle mode literally named "dev" when a `lifecycle`
        // section is present at all — there's no more `default_mode` fallback to fall back to
        // (removed in LAB-2040), so a project with `lifecycle.modes` but no "dev" entry is a
        // hard configuration error rather than a silent fallback to whatever mode happens to be
        // first/last-built.
        let lifecycle = CliLifecycleManager::from_project_config(&config);
        ensure_dev_mode_configured(lifecycle.as_ref())?;
        if let Some(ref lifecycle_mgr) = lifecycle {
            tracing::info!("Lifecycle management enabled. Mode: {}", lifecycle_mgr.current_mode());
        }

        // Create log buffer for TUI
        let log_buffer = LogBuffer::new(1000);

        // `--sim-visible` is threaded to the `simulator` node's subprocess via an environment
        // variable rather than a parameter plumbed through every `spawn_nodes_by_name`/
        // `handle_mode_change` call site (initial spawn, `t`/`s` mode-change keypresses, etc.) -
        // `ops::spawn::maybe_inject_sim_visible_env` reads this once per spawn batch and decides
        // whether to inject `MECHA10_SIMULATOR_VISIBLE` (see that function's docs). Process-wide
        // for the lifetime of this `mecha10 dev` invocation, same as `RUST_LOG`/
        // `MECHA10_ENVIRONMENT` elsewhere in the codebase.
        if args.sim_visible {
            std::env::set_var(ops::SIM_VISIBLE_ENV_VAR, "1");
        }

        Ok(Self {
            project_name,
            project_version,
            config,
            nodes,
            node_registry: NodeRegistry::new(),
            should_build: !args.no_build,
            watch_mode: args.watch,
            cli_nodes_specified: !args.nodes.is_empty(),
            lifecycle,
            log_buffer,
        })
    }

    /// Setup infrastructure (Docker services, Redis)
    ///
    /// Starts Docker services if configured and sets up Redis connection.
    ///
    /// # Arguments
    ///
    /// * `ctx` - CLI context
    pub async fn setup_infrastructure(&self, ctx: &mut CliContext) -> Result<()> {
        // Start Docker services if configured
        if self.config.docker.auto_start {
            ops::start_docker_services(ctx, &self.config, &self.project_name).await?;
        }

        // Setup Redis connection
        ops::setup_redis_connection(ctx).await?;

        Ok(())
    }

    /// Build nodes if needed
    ///
    /// # Arguments
    ///
    /// * `ctx` - CLI context
    pub fn build_if_needed(&self, ctx: &mut CliContext) -> Result<()> {
        if self.should_build {
            ops::build_nodes(ctx)?;
        }
        Ok(())
    }

    /// Spawn node processes
    ///
    /// Spawns nodes based on either CLI arguments or lifecycle mode configuration.
    /// If specific nodes were passed on CLI (e.g., `mecha10 dev camera`),
    /// those nodes are spawned regardless of lifecycle mode.
    ///
    /// # Arguments
    ///
    /// * `ctx` - CLI context
    pub async fn spawn_nodes(&mut self, ctx: &mut CliContext) -> Result<()> {
        let lifecycle = self.lifecycle.as_mut().ok_or_else(|| {
            anyhow::anyhow!(
                "Lifecycle configuration is required. Please add a 'lifecycle' section to your mecha10.json"
            )
        })?;

        // Determine which nodes to spawn:
        // - If CLI specified nodes, use those (overrides lifecycle mode)
        // - Otherwise, use lifecycle mode's nodes
        let nodes_to_spawn: Vec<String> = if self.cli_nodes_specified {
            // CLI-specified nodes take priority over lifecycle mode
            tracing::info!(
                "CLI specified nodes: {:?} (overriding lifecycle mode '{}')",
                self.nodes.iter().map(|n| &n.name).collect::<Vec<_>>(),
                lifecycle.current_mode()
            );
            self.nodes.iter().map(|n| n.name.clone()).collect()
        } else {
            // Use lifecycle mode's nodes
            let mode_nodes = lifecycle.nodes_for_current_mode();
            tracing::info!(
                "Spawning nodes for mode '{}': {:?}",
                lifecycle.current_mode(),
                mode_nodes
            );
            mode_nodes
        };

        // Log initial mode
        self.log_buffer
            .push(format!("MODE: Starting in {} mode", lifecycle.current_mode()));

        let outcome = ops::spawn_nodes_by_name(
            ctx,
            &nodes_to_spawn,
            &self.project_name,
            &self.config,
            Some(&self.log_buffer),
        )?;
        // Marks successfully spawned nodes as running and records failures as this session's
        // last-known failure per node — a later lifecycle mode change may never re-attempt a
        // node that already failed here (e.g. if it's not part of the target mode's own node
        // list), so this is the only place that failure reason is recorded.
        self.node_registry.record_spawn_outcome(&outcome);

        // Mark only successfully spawned nodes as running in lifecycle manager
        let actually_running: Vec<String> = self.node_registry.running_node_names();
        lifecycle.mark_nodes_running(&actually_running);

        if !outcome.failures.is_empty() {
            // Surface this clearly rather than letting startup look fully
            // successful when some configured nodes silently didn't start.
            println!("⚠️  {} node(s) failed to start:", outcome.failures.len());
            for (node, err) in &outcome.failures {
                println!("   ✗ {}: {}", node, err);
                self.log_buffer
                    .push(format!("ERROR: {} failed to start on session init: {}", node, err));
            }
            println!();
        }

        Ok(())
    }

    /// Run the dev session
    ///
    /// Enters either watch mode or interactive mode based on configuration.
    ///
    /// # Arguments
    ///
    /// * `ctx` - CLI context
    pub async fn run(&mut self, ctx: &mut CliContext) -> Result<()> {
        if self.watch_mode {
            ops::run_watch_mode(ctx, self.node_registry.pids().clone()).await?;
        } else {
            // Display initial mode status before entering interactive mode
            if let Some(ref lifecycle) = self.lifecycle {
                let running: Vec<String> = self.node_registry.running_node_names();
                dev_banners::print_mode_status(lifecycle.current_mode(), &running, &self.nodes);
            }

            let config = ops::InteractiveModeConfig {
                project_config: &self.config,
                nodes_to_run: &self.nodes,
                project_name: &self.project_name,
            };

            let mut state = ops::InteractiveModeState {
                node_registry: std::mem::take(&mut self.node_registry),
                lifecycle: self.lifecycle.take(),
                log_buffer: self.log_buffer.clone(),
            };

            ops::run_interactive_mode(ctx, config, &mut state).await?;

            // Restore state back to session
            self.node_registry = state.node_registry;
            self.lifecycle = state.lifecycle;
            self.log_buffer = state.log_buffer;
        }

        Ok(())
    }

    /// Complete lifecycle: setup, build, spawn, and run
    ///
    /// This is a convenience method that runs the entire dev session lifecycle.
    /// Model validation and pulling is now handled automatically by node-runner.
    ///
    /// # Arguments
    ///
    /// * `ctx` - CLI context
    pub async fn execute(mut self, ctx: &mut CliContext) -> Result<()> {
        self.setup_infrastructure(ctx).await?;
        self.build_if_needed(ctx)?;

        // Gate: fail fast, before spawning anything, when a configured node's Python
        // runtime dependency isn't installed (e.g. `simulator` without `mecha10_rl`) -
        // see `ops::preflight` module docs and LAB-1811. Only checks nodes actually
        // selected for this run (`self.nodes`), and is purely a cache-accelerated
        // convenience check - the per-node preflight in `simulator::run` still runs too.
        let project_root = std::env::current_dir()?;
        if let Err(message) = ops::run_dependency_preflight(&self.nodes, &project_root).await {
            eprintln!("{}", message);
            println!();
            return Err(anyhow::anyhow!(message));
        }

        // Start command listener for remote node control
        // Get shared process service BEFORE spawning nodes so they use the same instance
        let shared_process = ctx.shared_process();

        let (shutdown_tx, shutdown_rx) = tokio::sync::broadcast::channel::<()>(1);
        let listener_config = CommandListenerConfig {
            redis_url: ctx.redis_url().to_string(),
            project_name: self.project_name.clone(),
            robot_id: self.config.robot.id.clone(),
            control_plane_url: self.config.environments.control_plane_url(),
            relay_url: self.config.environments.relay_url(),
            log_buffer: Some(self.log_buffer.clone()),
        };
        let command_listener = build_command_listener(listener_config, shared_process.clone());

        let listener_handle = tokio::spawn(async move {
            if let Err(e) = command_listener.run(shutdown_rx).await {
                tracing::error!("Command listener error: {}", e);
            }
        });

        tracing::info!("Command listener started for remote node control");

        // Model pulling now handled by node-runner during spawn
        self.spawn_nodes(ctx).await?;
        self.run(ctx).await?;

        // Shutdown command listener
        let _ = shutdown_tx.send(());
        // Give it a moment to cleanup
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        listener_handle.abort();

        println!();
        Ok(())
    }
}

/// Enforce `mecha10 dev`'s hard requirement that a lifecycle-configured project defines a mode
/// literally named "dev" (LAB-2040 removed the `lifecycle.default_mode` fallback this used to
/// lean on). A no-op when there's no lifecycle config at all — lifecycle management is optional.
/// Pure/side-effect-free so it's unit-testable without spinning up a live project directory.
fn ensure_dev_mode_configured(lifecycle: Option<&CliLifecycleManager>) -> Result<()> {
    match lifecycle {
        Some(lifecycle_mgr) if !lifecycle_mgr.has_mode("dev") => Err(anyhow::anyhow!(
            "No \"dev\" mode found in lifecycle.modes. `mecha10 dev` requires a lifecycle mode \
             literally named \"dev\" — add one to mecha10.json's lifecycle.modes."
        )),
        _ => Ok(()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::project::{LifecycleConfig, ModeConfig, NodesConfig, RobotConfig};
    use std::collections::HashMap;

    fn project_config_with_modes(mode_names: &[&str]) -> ProjectConfig {
        let mut modes = HashMap::new();
        for name in mode_names {
            modes.insert(name.to_string(), ModeConfig { nodes: Vec::new() });
        }

        ProjectConfig {
            name: "test".to_string(),
            version: "0.1.0".to_string(),
            robot: RobotConfig {
                id: "test".to_string(),
                platform: None,
                description: None,
            },
            lifecycle: Some(LifecycleConfig { modes }),
            simulation: None,
            nodes: NodesConfig::new(),
            targets: None,
            behaviors: None,
            services: Default::default(),
            docker: Default::default(),
            environments: Default::default(),
        }
    }

    #[test]
    fn ensure_dev_mode_configured_is_ok_without_any_lifecycle_config() {
        assert!(ensure_dev_mode_configured(None).is_ok());
    }

    #[test]
    fn ensure_dev_mode_configured_is_ok_when_dev_mode_present() {
        let config = project_config_with_modes(&["dev", "simulation"]);
        let lifecycle_mgr = CliLifecycleManager::from_project_config(&config).unwrap();

        assert!(ensure_dev_mode_configured(Some(&lifecycle_mgr)).is_ok());
    }

    #[test]
    fn ensure_dev_mode_configured_errors_when_dev_mode_missing() {
        let config = project_config_with_modes(&["production", "simulation"]);
        let lifecycle_mgr = CliLifecycleManager::from_project_config(&config).unwrap();

        let err = ensure_dev_mode_configured(Some(&lifecycle_mgr)).unwrap_err();
        assert!(err.to_string().contains("\"dev\""));
        assert!(err.to_string().contains("lifecycle.modes"));
    }
}