Skip to main content

orcs_lua/
component.rs

1//! LuaComponent implementation.
2//!
3//! Wraps a Lua script to implement the Component trait.
4
5mod ctx_fns;
6mod emitter_fns;
7
8/// Truncate a string to at most `max_bytes`, respecting UTF-8 char boundaries.
9fn truncate_utf8(s: &str, max_bytes: usize) -> &str {
10    if s.len() <= max_bytes {
11        return s;
12    }
13    let mut end = max_bytes;
14    while end > 0 && !s.is_char_boundary(end) {
15        end -= 1;
16    }
17    &s[..end]
18}
19
20use crate::error::LuaError;
21use crate::lua_env::LuaEnv;
22use crate::types::{
23    parse_event_category, parse_signal_response, LuaRequest, LuaResponse, LuaSignal,
24};
25use mlua::{Function, IntoLua, Lua, LuaSerdeExt, RegistryKey, Table, Value as LuaValue};
26use orcs_component::{
27    ChildContext, Component, ComponentError, ComponentLoader, ComponentSnapshot, Emitter,
28    EventCategory, RuntimeHints, SnapshotError, SpawnError, Status, SubscriptionEntry,
29};
30use orcs_event::{Request, Signal, SignalResponse};
31use orcs_runtime::sandbox::SandboxPolicy;
32use orcs_types::ComponentId;
33use parking_lot::Mutex;
34use serde_json::Value as JsonValue;
35use std::path::Path;
36use std::sync::Arc;
37
38/// Extracts a [`ComponentError::Suspended`] from a potentially nested `mlua::Error` chain.
39///
40/// Lua wraps callback errors in `CallbackError { cause }` at each call-stack level.
41/// This helper recursively unwraps `CallbackError` and checks `ExternalError` for
42/// a boxed `ComponentError::Suspended`. Returns `None` if the error is not a Suspended.
43fn extract_suspended(err: &mlua::Error) -> Option<ComponentError> {
44    match err {
45        mlua::Error::ExternalError(ext) => ext
46            .downcast_ref::<ComponentError>()
47            .filter(|ce| matches!(ce, ComponentError::Suspended { .. }))
48            .cloned(),
49        mlua::Error::CallbackError { cause, .. } => extract_suspended(cause),
50        _ => None,
51    }
52}
53
54/// A component implemented in Lua.
55///
56/// Loads a Lua script and delegates Component trait methods to Lua functions.
57///
58/// # Script Format
59///
60/// The Lua script must return a table with the following structure:
61///
62/// ```lua
63/// return {
64///     id = "component-id",           -- Required: unique identifier
65///     subscriptions = {"Echo"},      -- Required: event categories
66///
67///     on_request = function(req)     -- Required: handle requests
68///         return { success = true, data = ... }
69///     end,
70///
71///     on_signal = function(sig)      -- Required: handle signals
72///         return "Handled" | "Ignored" | "Abort"
73///     end,
74///
75///     init = function()              -- Optional: initialization
76///     end,
77///
78///     shutdown = function()          -- Optional: cleanup
79///     end,
80/// }
81/// ```
82pub struct LuaComponent {
83    /// Lua runtime (wrapped in Mutex for Send+Sync).
84    lua: Mutex<Lua>,
85    /// Component identifier.
86    id: ComponentId,
87    /// Subscribed event categories (for Component::subscriptions()).
88    subscriptions: Vec<EventCategory>,
89    /// Subscription entries with optional operation filters (for Component::subscription_entries()).
90    subscription_entries: Vec<SubscriptionEntry>,
91    /// Current status.
92    status: Status,
93    /// Registry key for on_request callback.
94    on_request_key: RegistryKey,
95    /// Registry key for on_signal callback.
96    on_signal_key: RegistryKey,
97    /// Registry key for init callback (optional).
98    init_key: Option<RegistryKey>,
99    /// Registry key for shutdown callback (optional).
100    shutdown_key: Option<RegistryKey>,
101    /// Registry key for snapshot callback (optional).
102    snapshot_key: Option<RegistryKey>,
103    /// Registry key for restore callback (optional).
104    restore_key: Option<RegistryKey>,
105    /// Script path (for hot reload).
106    script_path: Option<String>,
107    /// Event emitter for ChannelRunner mode.
108    ///
109    /// When set, allows Lua scripts to emit events via `orcs.output()`.
110    /// This enables ChannelRunner-based execution (IO-less, event-only).
111    emitter: Option<Arc<Mutex<Box<dyn Emitter>>>>,
112    /// Child context for spawning and managing children.
113    ///
114    /// When set, allows Lua scripts to spawn children via `orcs.spawn_child()`.
115    /// This enables the Manager-Worker pattern where Components manage Children.
116    child_context: Option<Arc<Mutex<Box<dyn ChildContext>>>>,
117    /// Sandbox policy for file operations.
118    ///
119    /// Injected at construction time. Used by `orcs.read/write/grep/glob` in Lua.
120    /// Stored for use during `reload()`.
121    sandbox: Arc<dyn SandboxPolicy>,
122    /// Runtime hints declared by the Lua script.
123    hints: RuntimeHints,
124}
125
126// SAFETY: LuaComponent can be safely sent between threads and accessed concurrently.
127//
128// Justification:
129// 1. mlua is built with "send" feature (see Cargo.toml), which enables thread-safe
130//    Lua state allocation and makes the allocator thread-safe.
131// 2. The Lua runtime is wrapped in parking_lot::Mutex<Lua>, ensuring exclusive
132//    mutable access. All methods that access the Lua state acquire the lock first.
133// 3. All Lua callbacks are stored in the Lua registry via RegistryKey, which is
134//    designed for this use case. RegistryKey itself is Send.
135// 4. No raw Lua values (userdata, functions) escape the Mutex guard scope.
136//    Values are converted to/from Rust types within the lock scope.
137// 5. The remaining fields (id, subscriptions, status, script_path) are all Send+Sync.
138//
139// The "send" feature documentation: https://docs.rs/mlua/latest/mlua/#async-send
140//
141// FOR AI: #[allow(unsafe_code)] MUST be placed on each `unsafe impl` individually.
142// DO NOT use crate-root #![allow(unsafe_code)] — that disables the unsafe_code lint
143// for the entire crate, silently allowing any future unsafe code to go undetected.
144// mlua's `Lua` type is !Send + !Sync, so these manual Send/Sync impls are the ONLY
145// places in this crate that require unsafe. Keep the allow scoped here exclusively.
146//
147// SAFETY: see justification above (points 1-5).
148#[allow(unsafe_code)]
149unsafe impl Send for LuaComponent {}
150#[allow(unsafe_code)]
151unsafe impl Sync for LuaComponent {}
152
153impl LuaComponent {
154    /// Creates a new LuaComponent from a script file.
155    ///
156    /// # Arguments
157    ///
158    /// * `path` - Path to the Lua script
159    /// * `sandbox` - Sandbox policy for file operations and exec cwd
160    ///
161    /// # Errors
162    ///
163    /// Returns error if:
164    /// - Script file not found
165    /// - Script syntax error
166    /// - Missing required fields/callbacks
167    pub fn from_file<P: AsRef<Path>>(
168        path: P,
169        sandbox: Arc<dyn SandboxPolicy>,
170    ) -> Result<Self, LuaError> {
171        let path = path.as_ref();
172        let script = std::fs::read_to_string(path)
173            .map_err(|_| LuaError::ScriptNotFound(path.display().to_string()))?;
174
175        let script_dir = path.parent().map(|p| p.to_path_buf());
176        let mut component = Self::from_script_inner(&script, sandbox, script_dir.as_deref(), None)?;
177        component.script_path = Some(path.display().to_string());
178        Ok(component)
179    }
180
181    /// Creates a new LuaComponent from a directory containing `init.lua`.
182    ///
183    /// The directory is added to Lua's `package.path`, enabling standard
184    /// `require()` for co-located modules (e.g. `require("lib.my_module")`).
185    ///
186    /// # Directory Structure
187    ///
188    /// ```text
189    /// components/my_component/
190    ///   init.lua              -- entry point (must return component table)
191    ///   lib/
192    ///     helper.lua          -- require("lib.helper")
193    ///   vendor/
194    ///     lua_solver/init.lua -- require("vendor.lua_solver")
195    /// ```
196    ///
197    /// # Errors
198    ///
199    /// Returns error if `init.lua` not found or script is invalid.
200    pub fn from_dir<P: AsRef<Path>>(
201        dir: P,
202        sandbox: Arc<dyn SandboxPolicy>,
203    ) -> Result<Self, LuaError> {
204        let dir = dir.as_ref();
205        let init_path = dir.join("init.lua");
206        let script = std::fs::read_to_string(&init_path)
207            .map_err(|_| LuaError::ScriptNotFound(init_path.display().to_string()))?;
208
209        let mut component = Self::from_script_inner(&script, sandbox, Some(dir), None)?;
210        component.script_path = Some(init_path.display().to_string());
211        Ok(component)
212    }
213
214    /// Creates a new LuaComponent from a script string.
215    ///
216    /// # Arguments
217    ///
218    /// * `script` - Lua script content
219    /// * `sandbox` - Sandbox policy for file operations and exec cwd
220    ///
221    /// # Errors
222    ///
223    /// Returns error if script is invalid.
224    pub fn from_script(script: &str, sandbox: Arc<dyn SandboxPolicy>) -> Result<Self, LuaError> {
225        Self::from_script_inner(script, sandbox, None, None)
226    }
227
228    /// Creates a new LuaComponent from a script string with pre-injected globals.
229    ///
230    /// Each top-level key in `globals` is set as a Lua global variable before
231    /// the script executes, enabling structured data passing without string
232    /// serialization.
233    ///
234    /// # Design: `Map<String, Value>` instead of `serde_json::Value`
235    ///
236    /// The parameter accepts `Map<String, Value>` (a JSON object) rather than
237    /// an arbitrary `serde_json::Value`. This follows the *parse, don't validate*
238    /// principle: callers must produce a `Map` at the boundary, so this function
239    /// never encounters non-object values and needs no `unreachable!()` fallback.
240    /// See [`ComponentLoader::load_from_script`] for the trait-level rationale.
241    pub fn from_script_with_globals(
242        script: &str,
243        sandbox: Arc<dyn SandboxPolicy>,
244        globals: Option<&serde_json::Map<String, serde_json::Value>>,
245    ) -> Result<Self, LuaError> {
246        Self::from_script_inner(script, sandbox, None, globals)
247    }
248
249    /// Internal: creates a LuaComponent with optional search path setup and globals.
250    ///
251    /// When `script_dir` is provided, it is added to `LuaEnv`'s search paths
252    /// so that `require()` resolves co-located modules with sandbox validation.
253    /// When `globals` is provided, each top-level key is set as a Lua global
254    /// before the script executes.
255    fn from_script_inner(
256        script: &str,
257        sandbox: Arc<dyn SandboxPolicy>,
258        script_dir: Option<&Path>,
259        globals: Option<&serde_json::Map<String, serde_json::Value>>,
260    ) -> Result<Self, LuaError> {
261        // Build LuaEnv with sandbox and optional script directory as search path.
262        let mut lua_env = LuaEnv::new(Arc::clone(&sandbox));
263        if let Some(dir) = script_dir {
264            lua_env = lua_env.with_search_path(dir);
265        }
266
267        // Create configured Lua VM (orcs.*, tools, sandboxed require).
268        let lua = lua_env.create_lua()?;
269
270        // Register Component-specific output placeholders.
271        // These are overridden by real emitter functions via set_emitter().
272        {
273            let orcs_table: Table = lua.globals().get("orcs")?;
274            let output_noop = lua.create_function(|_, msg: String| {
275                tracing::warn!(
276                    "[lua] orcs.output called without emitter (noop): {}",
277                    truncate_utf8(&msg, 100)
278                );
279                Ok(())
280            })?;
281            orcs_table.set("output", output_noop)?;
282
283            let output_level_noop = lua.create_function(|_, (msg, _level): (String, String)| {
284                tracing::warn!(
285                    "[lua] orcs.output_with_level called without emitter (noop): {}",
286                    truncate_utf8(&msg, 100)
287                );
288                Ok(())
289            })?;
290            orcs_table.set("output_with_level", output_level_noop)?;
291        }
292
293        // Inject globals into VM before script execution.
294        // Each top-level key in the Map becomes a Lua global variable.
295        // The type signature guarantees `globals` is already a JSON object —
296        // no variant matching or `unreachable!()` needed.
297        if let Some(map) = globals {
298            let lua_globals = lua.globals();
299            for (k, v) in map {
300                let lua_val = json_to_lua_value(&lua, v)?;
301                lua_globals.set(k.as_str(), lua_val)?;
302            }
303        }
304
305        // Execute script and get the returned table
306        let component_table: Table = lua
307            .load(script)
308            .eval()
309            .map_err(|e| LuaError::InvalidScript(e.to_string()))?;
310
311        // Extract id and namespace
312        let id_str: String = component_table
313            .get("id")
314            .map_err(|_| LuaError::MissingCallback("id".to_string()))?;
315        let namespace: String = component_table
316            .get("namespace")
317            .unwrap_or_else(|_| "lua".to_string());
318        let id = ComponentId::new(namespace, &id_str);
319
320        // Extract subscriptions (supports both string and table entries)
321        //
322        // String form (all operations):
323        //   subscriptions = { "Echo", "UserInput" }
324        //
325        // Table form (specific operations):
326        //   subscriptions = {
327        //       "UserInput",
328        //       { category = "Extension", operations = {"route_response"} },
329        //   }
330        let subs_table: Table = component_table
331            .get("subscriptions")
332            .map_err(|_| LuaError::MissingCallback("subscriptions".to_string()))?;
333
334        let mut subscriptions = Vec::new();
335        let mut subscription_entries = Vec::new();
336        for pair in subs_table.pairs::<i64, LuaValue>() {
337            let (_, value) = pair.map_err(|e| LuaError::TypeError(e.to_string()))?;
338            match &value {
339                LuaValue::String(s) => {
340                    let cat_str = s.to_str().map_err(|e| LuaError::TypeError(e.to_string()))?;
341                    if let Some(cat) = parse_event_category(&cat_str) {
342                        subscriptions.push(cat.clone());
343                        subscription_entries.push(SubscriptionEntry::all(cat));
344                    }
345                }
346                LuaValue::Table(tbl) => {
347                    // Table form: { category = "Extension", operations = {"op1", "op2"} }
348                    let cat_str: String = tbl.get("category").map_err(|e| {
349                        LuaError::TypeError(format!(
350                            "subscription table must have 'category' field: {e}"
351                        ))
352                    })?;
353                    if let Some(cat) = parse_event_category(&cat_str) {
354                        subscriptions.push(cat.clone());
355                        // Parse optional operations list
356                        let ops_table: Option<Table> = tbl.get("operations").ok();
357                        if let Some(ops) = ops_table {
358                            let mut op_names = Vec::new();
359                            for (_, op) in ops.pairs::<i64, String>().flatten() {
360                                op_names.push(op);
361                            }
362                            subscription_entries
363                                .push(SubscriptionEntry::with_operations(cat, op_names));
364                        } else {
365                            subscription_entries.push(SubscriptionEntry::all(cat));
366                        }
367                    }
368                }
369                _ => {
370                    tracing::warn!("subscription entry must be a string or table, ignoring");
371                }
372            }
373        }
374
375        // Extract required callbacks
376        let on_request_fn: Function = component_table
377            .get("on_request")
378            .map_err(|_| LuaError::MissingCallback("on_request".to_string()))?;
379
380        let on_signal_fn: Function = component_table
381            .get("on_signal")
382            .map_err(|_| LuaError::MissingCallback("on_signal".to_string()))?;
383
384        // Store callbacks in registry
385        let on_request_key = lua.create_registry_value(on_request_fn)?;
386        let on_signal_key = lua.create_registry_value(on_signal_fn)?;
387
388        // Extract optional callbacks
389        let init_key = component_table
390            .get::<Function>("init")
391            .ok()
392            .map(|f| lua.create_registry_value(f))
393            .transpose()?;
394
395        let shutdown_key = component_table
396            .get::<Function>("shutdown")
397            .ok()
398            .map(|f| lua.create_registry_value(f))
399            .transpose()?;
400
401        let snapshot_key = component_table
402            .get::<Function>("snapshot")
403            .ok()
404            .map(|f| lua.create_registry_value(f))
405            .transpose()?;
406
407        let restore_key = component_table
408            .get::<Function>("restore")
409            .ok()
410            .map(|f| lua.create_registry_value(f))
411            .transpose()?;
412
413        // Extract runtime hints (all optional, default false)
414        let hints = RuntimeHints {
415            output_to_io: component_table.get("output_to_io").unwrap_or(false),
416            elevated: component_table.get("elevated").unwrap_or(false),
417            child_spawner: component_table.get("child_spawner").unwrap_or(false),
418        };
419
420        Ok(Self {
421            lua: Mutex::new(lua),
422            id,
423            subscriptions,
424            subscription_entries,
425            status: Status::Idle,
426            on_request_key,
427            on_signal_key,
428            init_key,
429            shutdown_key,
430            snapshot_key,
431            restore_key,
432            script_path: None,
433            emitter: None,
434            child_context: None,
435            sandbox,
436            hints,
437        })
438    }
439
440    /// Provides closure-based access to the internal Lua state.
441    ///
442    /// Intended for test mock injection (e.g. overriding `orcs.llm()`).
443    #[cfg(any(test, feature = "test-utils"))]
444    pub(crate) fn with_lua<F, R>(&self, f: F) -> R
445    where
446        F: FnOnce(&Lua) -> R,
447    {
448        let lua = self.lua.lock();
449        f(&lua)
450    }
451
452    /// Returns the script path if loaded from file.
453    #[must_use]
454    pub fn script_path(&self) -> Option<&str> {
455        self.script_path.as_deref()
456    }
457
458    /// Reloads the script from file.
459    ///
460    /// # Errors
461    ///
462    /// Returns error if reload fails.
463    pub fn reload(&mut self) -> Result<(), LuaError> {
464        let Some(path) = &self.script_path else {
465            return Err(LuaError::InvalidScript("no script path".into()));
466        };
467
468        let new_component = Self::from_file(path, Arc::clone(&self.sandbox))?;
469
470        // Swap internals (preserve emitter)
471        self.lua = new_component.lua;
472        self.subscriptions = new_component.subscriptions;
473        self.on_request_key = new_component.on_request_key;
474        self.on_signal_key = new_component.on_signal_key;
475        self.init_key = new_component.init_key;
476        self.shutdown_key = new_component.shutdown_key;
477        self.snapshot_key = new_component.snapshot_key;
478        self.restore_key = new_component.restore_key;
479        // Note: emitter is preserved across reload
480
481        // Re-register orcs.output if emitter is set
482        if let Some(emitter) = &self.emitter {
483            let lua = self.lua.lock();
484            emitter_fns::register(&lua, Arc::clone(emitter))?;
485        }
486
487        // Re-register child context functions if child_context is set
488        if let Some(ctx) = &self.child_context {
489            let lua = self.lua.lock();
490            ctx_fns::register(&lua, Arc::clone(ctx), Arc::clone(&self.sandbox))?;
491        }
492
493        tracing::info!("Reloaded Lua component: {}", self.id);
494        Ok(())
495    }
496
497    /// Returns whether this component has an emitter set.
498    ///
499    /// When true, the component can emit events via `orcs.output()`.
500    #[must_use]
501    pub fn has_emitter(&self) -> bool {
502        self.emitter.is_some()
503    }
504
505    /// Returns whether this component has a child context set.
506    ///
507    /// When true, the component can spawn children via `orcs.spawn_child()`.
508    #[must_use]
509    pub fn has_child_context(&self) -> bool {
510        self.child_context.is_some()
511    }
512
513    /// Sets the child context for spawning and managing children.
514    ///
515    /// Once set, the Lua script can use:
516    /// - `orcs.spawn_child(config)` - Spawn a child
517    /// - `orcs.child_count()` - Get current child count
518    /// - `orcs.max_children()` - Get max allowed children
519    ///
520    /// # Arguments
521    ///
522    /// * `ctx` - The child context
523    pub fn set_child_context(&mut self, ctx: Box<dyn ChildContext>) {
524        self.install_child_context(ctx);
525    }
526
527    /// Shared implementation for `set_child_context` (inherent + trait).
528    ///
529    /// Extracts hook registry and MCP manager, registers ctx functions,
530    /// and wires up hooks and MCP tools.
531    fn install_child_context(&mut self, ctx: Box<dyn ChildContext>) {
532        let hook_registry = ctx
533            .extension("hook_registry")
534            .and_then(|any| any.downcast::<orcs_hook::SharedHookRegistry>().ok())
535            .map(|boxed| *boxed);
536
537        let mcp_manager = ctx
538            .extension("mcp_manager")
539            .and_then(|any| {
540                any.downcast::<std::sync::Arc<orcs_mcp::McpClientManager>>()
541                    .ok()
542            })
543            .map(|boxed| *boxed);
544
545        let ctx_arc = Arc::new(Mutex::new(ctx));
546        self.child_context = Some(Arc::clone(&ctx_arc));
547
548        let lua = self.lua.lock();
549
550        if let Err(e) = ctx_fns::register(&lua, ctx_arc, Arc::clone(&self.sandbox)) {
551            tracing::warn!("Failed to register child context functions: {}", e);
552        }
553
554        // Wire MCP client manager into Lua app_data and register MCP IntentDefs.
555        // This must run regardless of hook_registry presence — MCP and hooks
556        // are independent features.
557        if let Some(manager) = mcp_manager {
558            // Store manager in app_data for dispatch_mcp
559            lua.set_app_data(crate::tool_registry::SharedMcpManager(
560                std::sync::Arc::clone(&manager),
561            ));
562
563            // Register MCP IntentDefs into IntentRegistry.
564            //
565            // SAFETY(runtime): block_in_place requires a multi-thread tokio runtime.
566            // This is guaranteed by OrcsApp which uses #[tokio::main].
567            // No RwLock on McpClientManager is held by the caller at this point;
568            // intent_defs() acquires tool_routes read lock internally.
569            if let Ok(handle) = tokio::runtime::Handle::try_current() {
570                let defs = tokio::task::block_in_place(|| handle.block_on(manager.intent_defs()));
571
572                if !defs.is_empty() {
573                    if let Some(mut registry) =
574                        lua.app_data_mut::<crate::tool_registry::IntentRegistry>()
575                    {
576                        for def in &defs {
577                            if let Err(e) = registry.register(def.clone()) {
578                                tracing::warn!(
579                                    intent = %def.name,
580                                    error = %e,
581                                    "Failed to register MCP intent"
582                                );
583                            }
584                        }
585                    } else {
586                        // IntentRegistry not yet created; store defs for deferred registration
587                        lua.set_app_data(crate::tool_registry::PendingMcpDefs(defs));
588                    }
589                    tracing::info!(
590                        component = %self.id.fqn(),
591                        "MCP client manager wired"
592                    );
593                }
594            }
595        }
596
597        // Hook registration — optional, does not block MCP.
598        let Some(registry) = hook_registry else {
599            return;
600        };
601
602        if let Err(e) =
603            crate::hook_helpers::register_hook_function(&lua, registry.clone(), self.id.clone())
604        {
605            tracing::warn!("Failed to register orcs.hook(): {}", e);
606        } else {
607            tracing::debug!(component = %self.id.fqn(), "orcs.hook() registered");
608        }
609
610        if let Err(e) = crate::hook_helpers::register_unhook_function(&lua, registry.clone()) {
611            tracing::warn!("Failed to register orcs.unhook(): {}", e);
612        }
613
614        lua.set_app_data(crate::tools::ToolHookContext {
615            registry,
616            component_id: self.id.clone(),
617        });
618        if let Err(e) = crate::tools::wrap_tools_with_hooks(&lua) {
619            tracing::warn!("Failed to wrap tools with hooks: {}", e);
620        }
621    }
622}
623
624impl Component for LuaComponent {
625    fn id(&self) -> &ComponentId {
626        &self.id
627    }
628
629    fn subscriptions(&self) -> &[EventCategory] {
630        &self.subscriptions
631    }
632
633    fn subscription_entries(&self) -> Vec<SubscriptionEntry> {
634        self.subscription_entries.clone()
635    }
636
637    fn runtime_hints(&self) -> RuntimeHints {
638        self.hints.clone()
639    }
640
641    fn status(&self) -> Status {
642        self.status
643    }
644
645    #[tracing::instrument(
646        skip(self, request),
647        fields(component = %self.id.fqn(), operation = %request.operation)
648    )]
649    fn on_request(&mut self, request: &Request) -> Result<JsonValue, ComponentError> {
650        if self.status == Status::Aborted {
651            return Err(ComponentError::ExecutionFailed(
652                "component is aborted".to_string(),
653            ));
654        }
655        self.status = Status::Running;
656
657        let lua = self.lua.lock();
658
659        // Get callback from registry
660        let on_request: Function = lua.registry_value(&self.on_request_key).map_err(|e| {
661            tracing::debug!("Failed to get on_request from registry: {}", e);
662            ComponentError::ExecutionFailed("lua callback not found".to_string())
663        })?;
664
665        // Convert request to Lua
666        let lua_req = LuaRequest::from_request(request);
667
668        // Call Lua function
669        let result: LuaResponse = on_request.call(lua_req).map_err(|e| {
670            // Propagate Suspended errors transparently — ChannelRunner needs
671            // the approval_id and grant_pattern to drive the HIL flow.
672            if let Some(suspended) = extract_suspended(&e) {
673                return suspended;
674            }
675            // Sanitize other error messages to avoid leaking internal details
676            tracing::debug!("Lua on_request error: {}", e);
677            ComponentError::ExecutionFailed("lua script execution failed".to_string())
678        })?;
679
680        drop(lua);
681        self.status = Status::Idle;
682
683        if result.success {
684            Ok(result.data.unwrap_or(JsonValue::Null))
685        } else {
686            Err(ComponentError::ExecutionFailed(
687                result.error.unwrap_or_else(|| "unknown error".into()),
688            ))
689        }
690    }
691
692    #[tracing::instrument(
693        skip(self, signal),
694        fields(component = %self.id.fqn(), signal_kind = ?signal.kind)
695    )]
696    fn on_signal(&mut self, signal: &Signal) -> SignalResponse {
697        let lua = self.lua.lock();
698
699        let Ok(on_signal): Result<Function, _> = lua.registry_value(&self.on_signal_key) else {
700            return SignalResponse::Ignored;
701        };
702
703        let lua_sig = LuaSignal::from_signal(signal);
704
705        let result: Result<String, _> = on_signal.call(lua_sig);
706
707        match result {
708            Ok(response_str) => {
709                let response = parse_signal_response(&response_str);
710                if matches!(response, SignalResponse::Abort) {
711                    drop(lua);
712                    self.status = Status::Aborted;
713                }
714                response
715            }
716            Err(e) => {
717                tracing::warn!("Lua on_signal error: {}", e);
718                SignalResponse::Ignored
719            }
720        }
721    }
722
723    fn abort(&mut self) {
724        self.status = Status::Aborted;
725    }
726
727    /// Calls the Lua `init(cfg)` callback with per-component settings.
728    ///
729    /// `config` contains `[components.settings.<name>]` from config.toml,
730    /// plus `_global` (injected by builder) with global config fields.
731    /// Null or empty objects are passed as `nil` to Lua.
732    #[tracing::instrument(skip(self, config), fields(component = %self.id.fqn()))]
733    fn init(&mut self, config: &serde_json::Value) -> Result<(), ComponentError> {
734        let Some(init_key) = &self.init_key else {
735            return Ok(());
736        };
737
738        let lua = self.lua.lock();
739
740        let init_fn: Function = lua.registry_value(init_key).map_err(|e| {
741            tracing::debug!("Failed to get init from registry: {}", e);
742            ComponentError::ExecutionFailed("lua init callback not found".to_string())
743        })?;
744
745        // Convert JSON config to Lua value; pass nil if null or empty object
746        let lua_config = if config.is_null()
747            || (config.is_object() && config.as_object().map_or(true, serde_json::Map::is_empty))
748        {
749            mlua::Value::Nil
750        } else {
751            lua.to_value(config).map_err(|e| {
752                tracing::debug!("Failed to convert config to Lua: {}", e);
753                ComponentError::ExecutionFailed("config conversion failed".to_string())
754            })?
755        };
756
757        init_fn.call::<()>(lua_config).map_err(|e| {
758            tracing::debug!("Lua init error: {}", e);
759            ComponentError::ExecutionFailed("lua init callback failed".to_string())
760        })?;
761
762        Ok(())
763    }
764
765    #[tracing::instrument(skip(self), fields(component = %self.id.fqn()))]
766    fn shutdown(&mut self) {
767        let Some(shutdown_key) = &self.shutdown_key else {
768            return;
769        };
770
771        let lua = self.lua.lock();
772
773        if let Ok(shutdown_fn) = lua.registry_value::<Function>(shutdown_key) {
774            if let Err(e) = shutdown_fn.call::<()>(()) {
775                tracing::warn!("Lua shutdown error: {}", e);
776            }
777        }
778    }
779
780    fn snapshot(&self) -> Result<ComponentSnapshot, SnapshotError> {
781        let Some(snapshot_key) = &self.snapshot_key else {
782            return Err(SnapshotError::NotSupported(self.id.fqn()));
783        };
784
785        let lua = self.lua.lock();
786
787        let snapshot_fn: Function = lua
788            .registry_value(snapshot_key)
789            .map_err(|e| SnapshotError::InvalidData(format!("snapshot callback not found: {e}")))?;
790
791        let lua_result: LuaValue = snapshot_fn
792            .call(())
793            .map_err(|e| SnapshotError::InvalidData(format!("snapshot callback failed: {e}")))?;
794
795        let json_value = lua_value_to_json(&lua_result);
796        ComponentSnapshot::from_state(self.id.fqn(), &json_value)
797    }
798
799    fn restore(&mut self, snapshot: &ComponentSnapshot) -> Result<(), SnapshotError> {
800        let Some(restore_key) = &self.restore_key else {
801            return Err(SnapshotError::NotSupported(self.id.fqn()));
802        };
803
804        snapshot.validate(&self.id.fqn())?;
805
806        let lua = self.lua.lock();
807
808        let restore_fn: Function = lua
809            .registry_value(restore_key)
810            .map_err(|e| SnapshotError::InvalidData(format!("restore callback not found: {e}")))?;
811
812        let lua_value = json_to_lua_value(&lua, &snapshot.state).map_err(|e| {
813            SnapshotError::InvalidData(format!("failed to convert snapshot to lua: {e}"))
814        })?;
815
816        restore_fn
817            .call::<()>(lua_value)
818            .map_err(|e| SnapshotError::RestoreFailed {
819                component: self.id.fqn(),
820                reason: format!("restore callback failed: {e}"),
821            })?;
822
823        Ok(())
824    }
825
826    fn set_emitter(&mut self, emitter: Box<dyn Emitter>) {
827        let emitter_arc = Arc::new(Mutex::new(emitter));
828        self.emitter = Some(Arc::clone(&emitter_arc));
829
830        // Register emitter-backed Lua functions (orcs.output, orcs.emit_event)
831        let lua = self.lua.lock();
832        if let Err(e) = emitter_fns::register(&lua, emitter_arc) {
833            tracing::warn!("Failed to register emitter functions: {}", e);
834        }
835    }
836
837    fn set_child_context(&mut self, ctx: Box<dyn ChildContext>) {
838        self.install_child_context(ctx);
839    }
840}
841
842/// ComponentLoader implementation for Lua components.
843///
844/// Allows creating LuaComponent instances from inline script content
845/// for use with ChildContext::spawn_runner_from_script().
846/// Optionally holds a builtin scripts map for name-based resolution.
847#[derive(Clone)]
848pub struct LuaComponentLoader {
849    sandbox: Arc<dyn SandboxPolicy>,
850    builtins: Option<Arc<std::collections::HashMap<String, String>>>,
851}
852
853impl LuaComponentLoader {
854    /// Creates a new LuaComponentLoader with the given sandbox policy.
855    #[must_use]
856    pub fn new(sandbox: Arc<dyn SandboxPolicy>) -> Self {
857        Self {
858            sandbox,
859            builtins: None,
860        }
861    }
862
863    /// Attaches a builtin scripts map for name-based resolution.
864    ///
865    /// Keys are relative filenames (e.g. `"concierge.lua"`),
866    /// values are the full script content.
867    /// Wrapped in `Arc` to allow cheap cloning across components.
868    #[must_use]
869    pub fn with_builtins(
870        mut self,
871        builtins: Arc<std::collections::HashMap<String, String>>,
872    ) -> Self {
873        self.builtins = Some(builtins);
874        self
875    }
876}
877
878impl ComponentLoader for LuaComponentLoader {
879    fn load_from_script(
880        &self,
881        script: &str,
882        _id: Option<&str>,
883        globals: Option<&serde_json::Map<String, serde_json::Value>>,
884    ) -> Result<Box<dyn Component>, SpawnError> {
885        // Note: id parameter is ignored; LuaComponent extracts ID from script
886        LuaComponent::from_script_with_globals(script, Arc::clone(&self.sandbox), globals)
887            .map(|c| Box::new(c) as Box<dyn Component>)
888            .map_err(|e| SpawnError::InvalidScript(e.to_string()))
889    }
890
891    fn resolve_builtin(&self, name: &str) -> Option<String> {
892        // Clones the script string on each resolve. Acceptable because this is
893        // called only once per spawn_runner_from_builtin invocation (not on a hot path).
894        self.builtins.as_ref()?.get(name).cloned()
895    }
896}
897
898// === JSON ↔ Lua conversion helpers for snapshot/restore ===
899
900/// Converts a Lua value to a serde_json::Value.
901fn lua_value_to_json(value: &LuaValue) -> JsonValue {
902    match value {
903        LuaValue::Nil => JsonValue::Null,
904        LuaValue::Boolean(b) => JsonValue::Bool(*b),
905        LuaValue::Integer(i) => JsonValue::Number((*i).into()),
906        LuaValue::Number(n) => serde_json::Number::from_f64(*n)
907            .map(JsonValue::Number)
908            .unwrap_or(JsonValue::Null),
909        LuaValue::String(s) => JsonValue::String(s.to_string_lossy().to_string()),
910        LuaValue::Table(table) => {
911            // Detect array vs object: check if sequential integer keys starting at 1
912            let len = table.raw_len();
913            let is_array = len > 0
914                && table
915                    .clone()
916                    .pairs::<i64, LuaValue>()
917                    .enumerate()
918                    .all(|(idx, pair)| pair.map(|(k, _)| k == (idx as i64 + 1)).unwrap_or(false));
919
920            if is_array {
921                let arr: Vec<JsonValue> = table
922                    .clone()
923                    .sequence_values::<LuaValue>()
924                    .filter_map(|v| v.ok())
925                    .map(|v| lua_value_to_json(&v))
926                    .collect();
927                JsonValue::Array(arr)
928            } else {
929                let mut map = serde_json::Map::new();
930                if let Ok(pairs) = table
931                    .clone()
932                    .pairs::<LuaValue, LuaValue>()
933                    .collect::<Result<Vec<_>, _>>()
934                {
935                    for (k, v) in pairs {
936                        let key = match &k {
937                            LuaValue::String(s) => s.to_string_lossy().to_string(),
938                            LuaValue::Integer(i) => i.to_string(),
939                            _ => continue,
940                        };
941                        map.insert(key, lua_value_to_json(&v));
942                    }
943                }
944                JsonValue::Object(map)
945            }
946        }
947        _ => JsonValue::Null,
948    }
949}
950
951/// Converts a serde_json::Value to a Lua value.
952fn json_to_lua_value(lua: &Lua, value: &JsonValue) -> Result<LuaValue, mlua::Error> {
953    match value {
954        JsonValue::Null => Ok(LuaValue::Nil),
955        JsonValue::Bool(b) => Ok(LuaValue::Boolean(*b)),
956        JsonValue::Number(n) => {
957            if let Some(i) = n.as_i64() {
958                Ok(LuaValue::Integer(i))
959            } else if let Some(f) = n.as_f64() {
960                Ok(LuaValue::Number(f))
961            } else {
962                Ok(LuaValue::Nil)
963            }
964        }
965        JsonValue::String(s) => s.as_str().into_lua(lua),
966        JsonValue::Array(arr) => {
967            let table = lua.create_table()?;
968            for (i, v) in arr.iter().enumerate() {
969                let lua_val = json_to_lua_value(lua, v)?;
970                table.raw_set(i + 1, lua_val)?;
971            }
972            Ok(LuaValue::Table(table))
973        }
974        JsonValue::Object(map) => {
975            let table = lua.create_table()?;
976            for (k, v) in map {
977                let lua_val = json_to_lua_value(lua, v)?;
978                table.raw_set(k.as_str(), lua_val)?;
979            }
980            Ok(LuaValue::Table(table))
981        }
982    }
983}
984
985#[cfg(test)]
986mod tests;
987
988#[cfg(test)]
989mod extract_suspended_tests {
990    use super::*;
991
992    #[test]
993    fn extracts_suspended_from_external_error() {
994        let suspended = ComponentError::Suspended {
995            approval_id: "ap-1".into(),
996            grant_pattern: "shell:*".into(),
997            pending_request: serde_json::json!({"cmd": "ls"}),
998        };
999        let err = mlua::Error::ExternalError(Arc::new(suspended));
1000        let result = extract_suspended(&err);
1001        assert!(
1002            result.is_some(),
1003            "should extract Suspended from ExternalError"
1004        );
1005        match result.expect("already checked is_some") {
1006            ComponentError::Suspended { approval_id, .. } => {
1007                assert_eq!(approval_id, "ap-1");
1008            }
1009            other => panic!("Expected Suspended, got {:?}", other),
1010        }
1011    }
1012
1013    #[test]
1014    fn extracts_suspended_from_callback_error() {
1015        let suspended = ComponentError::Suspended {
1016            approval_id: "ap-2".into(),
1017            grant_pattern: "tool:*".into(),
1018            pending_request: serde_json::Value::Null,
1019        };
1020        let inner = mlua::Error::ExternalError(Arc::new(suspended));
1021        let err = mlua::Error::CallbackError {
1022            traceback: "stack trace".into(),
1023            cause: Arc::new(inner),
1024        };
1025        let result = extract_suspended(&err);
1026        assert!(
1027            result.is_some(),
1028            "should extract Suspended through CallbackError"
1029        );
1030    }
1031
1032    #[test]
1033    fn extracts_suspended_from_nested_callback_errors() {
1034        let suspended = ComponentError::Suspended {
1035            approval_id: "ap-3".into(),
1036            grant_pattern: "exec:*".into(),
1037            pending_request: serde_json::Value::Null,
1038        };
1039        let inner = mlua::Error::ExternalError(Arc::new(suspended));
1040        let mid = mlua::Error::CallbackError {
1041            traceback: "level 1".into(),
1042            cause: Arc::new(inner),
1043        };
1044        let outer = mlua::Error::CallbackError {
1045            traceback: "level 2".into(),
1046            cause: Arc::new(mid),
1047        };
1048        let result = extract_suspended(&outer);
1049        assert!(
1050            result.is_some(),
1051            "should extract through nested CallbackErrors"
1052        );
1053    }
1054
1055    #[test]
1056    fn returns_none_for_non_suspended_component_error() {
1057        let err =
1058            mlua::Error::ExternalError(Arc::new(ComponentError::ExecutionFailed("timeout".into())));
1059        assert!(
1060            extract_suspended(&err).is_none(),
1061            "ExecutionFailed should not match"
1062        );
1063    }
1064
1065    #[test]
1066    fn returns_none_for_runtime_error() {
1067        let err = mlua::Error::RuntimeError("some error".into());
1068        assert!(
1069            extract_suspended(&err).is_none(),
1070            "RuntimeError should not match"
1071        );
1072    }
1073}