Skip to main content

theater_cli/commands/
spawn.rs

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