Skip to main content

theater_cli/commands/
spawn.rs

1use anyhow::Result;
2use clap::{Parser, ValueEnum};
3use std::io::Write;
4use std::path::PathBuf;
5use tokio::sync::mpsc;
6use tracing::{debug, error};
7
8use crate::{error::CliError, CommandContext};
9use theater::chain::ChainEvent;
10use theater::config::actor_manifest::{
11    RuntimeHostConfig, StoreHandlerConfig, SupervisorHostConfig, TcpHandlerConfig,
12    TerminalHandlerConfig, TimerHandlerConfig,
13};
14use theater::handler::HandlerRegistry;
15use theater::messages::{default_init_state, TheaterCommand};
16use theater::pack_bridge::Value;
17use theater::theater_runtime::TheaterRuntime;
18use theater::utils::resolve_reference;
19use theater::ManifestConfig;
20use theater::TheaterId;
21use theater_handler_loop::LoopHandler;
22use theater_handler_message_server::{MessageRouter, MessageServerHandler};
23use theater_handler_podman::PodmanHandler;
24use theater_handler_rpc::RpcHandler;
25use theater_handler_runtime::RuntimeHandler;
26use theater_handler_store::StoreHandler;
27use theater_handler_supervisor::SupervisorHandler;
28use theater_handler_tcp::TcpHandler;
29use theater_handler_terminal::TerminalHandler;
30use theater_handler_timer::TimerHandler;
31
32/// Output format for chain events
33#[derive(Debug, Clone, Copy, ValueEnum, Default)]
34pub enum EventFormat {
35    /// JSON format (one JSON object per line)
36    Json,
37    /// Short format (compact, one line per event)
38    #[default]
39    Short,
40    /// Full format (complete event data, multi-line)
41    Full,
42}
43
44/// Arguments shared by `theater spawn` and `theater setup`.
45#[derive(Debug, Parser)]
46pub struct SpawnArgs {
47    /// Path or URL to the actor manifest file
48    #[arg(default_value = "manifest.toml")]
49    pub manifest: String,
50
51    /// Output chain events from all actors
52    #[arg(long)]
53    pub events: bool,
54
55    /// Format for event output (used with --events)
56    #[arg(long, value_enum, default_value = "short")]
57    pub events_format: EventFormat,
58
59    /// Save the chain to a local directory after the actor exits.
60    /// Defaults to `.chains/` in the current directory.
61    #[arg(long, default_missing_value = ".chains", num_args = 0..=1)]
62    pub save: Option<PathBuf>,
63
64    /// Disable actor log output to stdout
65    #[arg(long)]
66    pub no_actor_logs: bool,
67}
68
69/// `theater setup` takes the same arguments as `theater spawn`.
70pub type SetupArgs = SpawnArgs;
71
72/// Format a chain event with actor ID prefix using ChainEvent's Display impl (short)
73fn format_event_short(event: &ChainEvent, actor_id: &TheaterId) -> String {
74    let id_str = actor_id.to_string();
75    let short_id = &id_str[..8.min(id_str.len())];
76    format!("[{}] {}\n", short_id, event)
77}
78
79/// Format a chain event with full data (multi-line, complete)
80fn format_event_full(event: &ChainEvent, actor_id: &TheaterId) -> String {
81    let id_str = actor_id.to_string();
82    let short_id = &id_str[..8.min(id_str.len())];
83    let hash_hex = hex::encode(&event.hash);
84    let parent_hex = event
85        .parent_hash
86        .as_ref()
87        .map(hex::encode)
88        .unwrap_or_else(|| "none".to_string());
89    let data_str = String::from_utf8_lossy(&event.data);
90
91    format!(
92        "EVENT [{}] {}\nparent: {}\ntype: {}\nsize: {}\n{}\n\n",
93        short_id,
94        hash_hex,
95        parent_hex,
96        event.event_type,
97        event.data.len(),
98        data_str
99    )
100}
101
102/// Format a chain event as JSON for stdout
103fn format_event_json(event: &ChainEvent, actor_id: &TheaterId) -> String {
104    let json = serde_json::json!({
105        "actor_id": actor_id.to_string(),
106        "hash": hex::encode(&event.hash),
107        "parent_hash": event.parent_hash.as_ref().map(hex::encode),
108        "event_type": event.event_type,
109        "data": format!("{} bytes (pack-encoded)", event.data.len())
110    });
111    serde_json::to_string(&json).unwrap_or_else(|_| "{}".to_string())
112}
113
114/// Create a handler registry with all Theater handlers
115fn create_handler_registry(
116    theater_tx: mpsc::Sender<TheaterCommand>,
117    show_actor_logs: bool,
118) -> Result<HandlerRegistry, CliError> {
119    let mut registry = HandlerRegistry::new();
120
121    // Runtime handler - provides log, get-chain, shutdown
122    let runtime_config = RuntimeHostConfig {};
123    registry.register(
124        RuntimeHandler::new(runtime_config, theater_tx.clone(), None)
125            .with_show_logs(show_actor_logs),
126    );
127
128    // Store handler - provides content storage
129    let store_config = StoreHandlerConfig::default();
130    registry.register(StoreHandler::new(store_config, None));
131
132    // Supervisor handler - allows spawning/managing child actors
133    let supervisor_config = SupervisorHostConfig {};
134    registry.register(SupervisorHandler::new(supervisor_config, None));
135
136    // Message server handler - inter-actor messaging
137    let message_router = MessageRouter::new();
138    registry.register(MessageServerHandler::new(None, message_router.clone()));
139
140    // RPC handler - direct actor-to-actor function calls
141    registry.register(RpcHandler::new(theater_tx.clone()));
142
143    // TCP handler - TCP server/client functionality
144    let tcp_config = TcpHandlerConfig {
145        listen: None,
146        max_connections: None,
147        ..Default::default()
148    };
149    registry.register(TcpHandler::new(tcp_config));
150
151    // Terminal handler - stdin/stdout/stderr for interactive CLI apps
152    let terminal_config = TerminalHandlerConfig::default();
153    registry.register(TerminalHandler::new(terminal_config));
154
155    // Timer handler - periodic tick callbacks for game loops, polling, etc.
156    let timer_config = TimerHandlerConfig::default();
157    registry.register(TimerHandler::new(timer_config));
158
159    // Loop handler - cooperative looping with yield points
160    registry.register(LoopHandler::new());
161
162    // Podman handler - container management via the podman CLI
163    let podman_config = theater::config::actor_manifest::PodmanHandlerConfig::default();
164    registry.register(PodmanHandler::new(podman_config));
165
166    Ok(registry)
167}
168
169/// `theater spawn manifest.toml` — load the actor, set up its task loops,
170/// AND call its `theater:simple/actor.init` export before returning control
171/// to the caller. The runtime auto-inits (PR A in ticket #27); the CLI
172/// doesn't fire init itself.
173pub async fn execute_spawn(args: &SpawnArgs, ctx: &CommandContext) -> Result<(), CliError> {
174    run(args, ctx, /* call_init = */ true).await
175}
176
177/// `theater setup manifest.toml` — load the actor and set up its task loops,
178/// but do NOT call `actor.init`. Used by replay (the replay handler walks
179/// the recorded chain and fires init from there) and by callers that want
180/// to drive init themselves with custom typed params.
181pub async fn execute_setup(args: &SetupArgs, ctx: &CommandContext) -> Result<(), CliError> {
182    run(args, ctx, /* call_init = */ false).await
183}
184
185/// Shared body for `spawn` and `setup`. Differs only in which
186/// `TheaterCommand` variant it dispatches.
187async fn run(args: &SpawnArgs, ctx: &CommandContext, call_init: bool) -> Result<(), CliError> {
188    debug!("Starting actor from manifest: {}", args.manifest);
189
190    // Resolve the manifest reference (file path, URL, or store path)
191    let manifest_bytes = resolve_reference(&args.manifest).await.map_err(|e| {
192        CliError::invalid_manifest(format!(
193            "Failed to resolve manifest reference '{}': {}",
194            args.manifest, e
195        ))
196    })?;
197
198    let manifest_content = String::from_utf8(manifest_bytes).map_err(|e| {
199        CliError::invalid_manifest(format!("Manifest content is not valid UTF-8: {}", e))
200    })?;
201
202    // (Chain writing is handled by the runtime's ChainWriter via runtime.chain_dir)
203
204    // Parse the manifest first (needed to check for replay handler)
205    let manifest = ManifestConfig::from_toml_str(&manifest_content)
206        .map_err(|e| CliError::invalid_manifest(format!("Failed to parse manifest: {}", e)))?;
207
208    // Create the TheaterRuntime in-process
209    let (theater_tx, theater_rx) = mpsc::channel::<TheaterCommand>(32);
210    let handler_registry = create_handler_registry(theater_tx.clone(), !args.no_actor_logs)?;
211
212    let mut runtime = TheaterRuntime::new(
213        theater_tx.clone(),
214        theater_rx,
215        None, // no channel events forwarding needed
216        handler_registry,
217    )
218    .await
219    .map_err(|e| CliError::server_error(format!("Failed to create runtime: {}", e)))?;
220
221    // Set chain output directory if --save is specified
222    if let Some(ref dir) = args.save {
223        runtime.chain_dir = Some(dir.clone());
224    }
225
226    // Set up global event subscription (receives events from ALL actors)
227    let (global_events_tx, mut global_events_rx) = mpsc::channel(256);
228    runtime.add_global_subscription(global_events_tx);
229
230    // Spawn the runtime event loop in a background task
231    let runtime_handle = tokio::spawn(async move {
232        if let Err(e) = runtime.run().await {
233            error!("Theater runtime error: {}", e);
234        }
235    });
236
237    // Resolve WASM path relative to manifest directory
238    let wasm_path = if manifest.package.starts_with('/') || manifest.package.contains("://") {
239        // Absolute path or URL - use as is
240        manifest.package.clone()
241    } else {
242        // Relative path - resolve relative to manifest's directory
243        let manifest_path = std::path::Path::new(&args.manifest);
244        if let Some(manifest_dir) = manifest_path.parent() {
245            manifest_dir
246                .join(&manifest.package)
247                .to_string_lossy()
248                .to_string()
249        } else {
250            manifest.package.clone()
251        }
252    };
253
254    // Load WASM bytes
255    let wasm_bytes = resolve_reference(&wasm_path).await.map_err(|e| {
256        CliError::server_error(format!("Failed to load WASM from '{}': {}", wasm_path, e))
257    })?;
258
259    // Spawn the actor
260    let (response_tx, response_rx) = tokio::sync::oneshot::channel();
261
262    // Set up a supervisor channel so we get notified when the actor exits
263    let (supervisor_tx, mut supervisor_rx) = mpsc::channel(32);
264
265    // The runtime stores `init_state` as the actor's initial state and
266    // (for SpawnActor) prepends it to the auto-fired actor.init call.
267    // For the CLI, the only place a caller can supply that state is the
268    // manifest's `initial_state` field — fall back to it here when set,
269    // otherwise use the conventional none sentinel.
270    //
271    // PR A (#58) moved this resolver out of `spawn_actor` with the intent
272    // that each caller does its own resolution; this line is the CLI's.
273    let init_state = match manifest.initial_state.as_ref() {
274        Some(s) => Value::String(s.clone()),
275        None => default_init_state(),
276    };
277
278    // SpawnActor: setup + auto-init (the runtime calls actor.init before
279    // responding). SetupActor: setup only — caller drives init separately
280    // (or a handler like ReplayHandler does it from the chain).
281    let cmd = if call_init {
282        TheaterCommand::SpawnActor {
283            wasm_bytes,
284            name: Some(manifest.name.clone()),
285            manifest: Some(manifest),
286            init_state,
287            response_tx,
288            supervisor_tx: Some(supervisor_tx),
289            subscription_tx: None, // Using global subscription instead
290        }
291    } else {
292        TheaterCommand::SetupActor {
293            wasm_bytes,
294            name: Some(manifest.name.clone()),
295            manifest: Some(manifest),
296            init_state,
297            response_tx,
298            supervisor_tx: Some(supervisor_tx),
299            subscription_tx: None, // Using global subscription instead
300        }
301    };
302
303    theater_tx
304        .send(cmd)
305        .await
306        .map_err(|e| CliError::server_error(format!("Failed to send spawn command: {}", e)))?;
307
308    // Wait for the actor to start (and, for SpawnActor, for init to complete).
309    let actor_id = match response_rx.await {
310        Ok(Ok(id)) => {
311            debug!("Actor started: {}", id);
312            id
313        }
314        Ok(Err(e)) => {
315            return Err(CliError::server_error(format!(
316                "Failed to start actor: {}",
317                e
318            )));
319        }
320        Err(e) => {
321            return Err(CliError::server_error(format!(
322                "Failed to receive spawn response: {}",
323                e
324            )));
325        }
326    };
327
328    // Now wait for either:
329    // - The actor to exit (supervisor notification)
330    // - Ctrl+C
331    // - Shutdown token cancellation
332    //
333    // Output modes:
334    // - Default: print only log messages as [actor-id] message
335    // - --events: print all chain events as JSON
336    // - --chain-dir: also persist events to files
337    loop {
338        tokio::select! {
339            // Actor result (exit/error)
340            result = supervisor_rx.recv() => {
341                match result {
342                    Some(actor_result) => {
343                        debug!("Actor exited: {:?}", actor_result);
344                        match actor_result {
345                            theater::messages::ActorResult::Success(success) => {
346                                if let Some(output) = success.result {
347                                    // Write actor result to stdout
348                                    let _ = std::io::stdout().write_all(&output);
349                                    let _ = std::io::stdout().flush();
350                                }
351                            }
352                            theater::messages::ActorResult::Error(err) => {
353                                eprintln!("Actor error: {}", err.error);
354                                std::process::exit(1);
355                            }
356                            theater::messages::ActorResult::ExternalStop(_) => {
357                                debug!("Actor stopped externally");
358                            }
359                        }
360                        break;
361                    }
362                    None => {
363                        // Supervisor channel closed, actor is done
364                        debug!("Supervisor channel closed");
365                        break;
366                    }
367                }
368            }
369
370            // Global event subscription (all actors)
371            event = global_events_rx.recv() => {
372                if let Some((event_actor_id, event_result)) = event {
373                    match event_result {
374                        Ok(chain_event) => {
375                            // Output events if --events mode is enabled
376                            // (Actor logs are printed directly by RuntimeHandler, not extracted here)
377                            if args.events {
378                                match args.events_format {
379                                    EventFormat::Json => {
380                                        println!("{}", format_event_json(&chain_event, &event_actor_id));
381                                    }
382                                    EventFormat::Short => {
383                                        print!("{}", format_event_short(&chain_event, &event_actor_id));
384                                    }
385                                    EventFormat::Full => {
386                                        print!("{}", format_event_full(&chain_event, &event_actor_id));
387                                    }
388                                }
389                            }
390
391                            // Check for root actor shutdown
392                            if event_actor_id == actor_id && chain_event.event_type == "shutdown" {
393                                break;
394                            }
395                        }
396                        Err(e) => {
397                            debug!("Actor error event: {:?}", e);
398                        }
399                    }
400                }
401            }
402
403            // Ctrl+C
404            _ = tokio::signal::ctrl_c() => {
405                debug!("Received Ctrl+C, stopping actor {}", actor_id);
406                eprintln!("\nStopping actor...");
407
408                let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
409                let _ = theater_tx.send(TheaterCommand::StopActor {
410                    actor_id,
411                    response_tx: stop_tx,
412                }).await;
413
414                // Wait briefly for graceful shutdown
415                match tokio::time::timeout(
416                    tokio::time::Duration::from_secs(5),
417                    stop_rx,
418                ).await {
419                    Ok(Ok(Ok(()))) => debug!("Actor stopped gracefully"),
420                    _ => debug!("Actor stop timed out or failed"),
421                }
422                break;
423            }
424
425            // Shutdown token
426            _ = ctx.shutdown_token.cancelled() => {
427                debug!("Shutdown token cancelled");
428                break;
429            }
430        }
431    }
432
433    // Drop the theater_tx to signal the runtime to stop
434    drop(theater_tx);
435
436    // Wait for runtime to finish (with timeout)
437    let _ = tokio::time::timeout(tokio::time::Duration::from_secs(5), runtime_handle).await;
438
439    Ok(())
440}