Skip to main content

algocline_engine/
executor.rs

1//! Lua execution engine.
2//!
3//! Orchestrates StdLib injection and Lua execution for each session:
4//!
5//! 1. **Layer 0** — [`bridge::register`] injects Rust-backed `alc.*` primitives
6//! 2. **Layer 1** — [`PRELUDE`] adds Lua-based combinators (`alc.map`, etc.)
7//! 3. **Layer 2** — [`mlua_pkg::Registry`] makes `require("ucb")` etc.
8//!    resolve from `~/.algocline/packages/`
9//!
10//! ## Execution models
11//!
12//! - **`eval_simple`** — sync eval on a shared VM (no LLM bridge).
13//!   For lightweight ops like reading package metadata.
14//! - **`start_session`** — spawns a **dedicated VM per session**.
15//!   Each session gets an isolated Lua VM so concurrent sessions
16//!   cannot interfere with each other's globals (`alc`, `ctx`).
17//!   `alc.llm()` yields the coroutine, and the VM is cleaned up
18//!   when the session completes or is abandoned.
19
20use std::collections::HashMap;
21use std::path::PathBuf;
22use std::sync::Arc;
23
24use algocline_core::{Budget, ExecutionMetrics, ExecutionSpec};
25use mlua::LuaSerdeExt;
26use mlua_isle::{AsyncIsle, AsyncIsleDriver, IsleError};
27use mlua_pkg::Registry;
28
29use crate::bridge;
30use crate::card::FileCardStore;
31use crate::llm_bridge::LlmRequest;
32use crate::resolver_factory::make_resolver;
33use crate::session::Session;
34use crate::state::JsonFileStore;
35use crate::variant_pkg::{register_variant_pkgs, VariantPkg};
36
37/// Layer 1: Prelude combinators (map, reduce, vote, filter).
38/// Embedded at compile time and loaded into every session.
39const PRELUDE: &str = include_str!("prelude.lua");
40
41/// Per-session storage roots resolved by the service layer.
42///
43/// Bundles the four filesystem handles that flow into every
44/// [`Executor::start_session`] / [`Executor::start_session_with_env`] call so
45/// callers don't juggle four parallel arguments. The service layer builds one
46/// from [`algocline_core::AppDir`] and hands it in; the engine consumes fields
47/// as needed.
48///
49/// Adding a new subsystem-owned path (e.g. a future `blob_dir`) is a single
50/// field on this struct rather than a sweep across every start_session caller.
51#[derive(Clone)]
52pub struct SessionDirs {
53    /// Backing store for `alc.state.*` (typically `<app_dir>/state`).
54    pub state_store: Arc<JsonFileStore>,
55    /// Backing store for `alc.card.*` (typically `<app_dir>/cards`).
56    pub card_store: Arc<FileCardStore>,
57    /// Directory exposed to Lua via `alc._dirs.scenarios`.
58    pub scenarios_dir: PathBuf,
59    /// Store root for `alc.nn.save` / `alc.nn.load` (typically `<app_dir>/nn`).
60    /// An empty [`PathBuf`] disables the store install (save/load then error);
61    /// consumed only when the `nn` feature is enabled.
62    pub nn_dir: PathBuf,
63}
64
65/// Lua execution engine.
66///
67/// Holds a **shared VM** for lightweight stateless operations (`eval_simple`)
68/// and spawns **per-session VMs** for coroutine-based execution (`start_session`).
69///
70/// Per-session VMs eliminate global namespace pollution between concurrent
71/// sessions — each session's `alc`, `ctx`, and `package.loaded` are fully
72/// isolated.
73pub struct Executor {
74    /// Shared VM for eval_simple (stateless, no session globals).
75    isle: AsyncIsle,
76    _driver: AsyncIsleDriver,
77    /// Package resolver paths, cloned into each per-session VM.
78    lib_paths: Vec<PathBuf>,
79}
80
81impl Executor {
82    pub async fn new(lib_paths: Vec<PathBuf>) -> anyhow::Result<Self> {
83        let paths_for_shared = lib_paths.clone();
84        let (isle, driver) = AsyncIsle::spawn(move |lua| {
85            let mut reg = Registry::new();
86            for path in &paths_for_shared {
87                if let Some(resolver) = make_resolver(path) {
88                    reg.add(resolver);
89                }
90            }
91            reg.install(lua)?;
92            Ok(())
93        })
94        .await?;
95
96        Ok(Self {
97            isle,
98            _driver: driver,
99            lib_paths,
100        })
101    }
102
103    /// Evaluate Lua code without LLM bridge. For lightweight operations
104    /// like reading package metadata.
105    ///
106    /// Uses the shared VM. `extra_lib_paths` must be empty — use
107    /// [`Self::eval_simple_with_paths`] when project-local paths are needed.
108    pub async fn eval_simple(&self, code: String) -> Result<serde_json::Value, String> {
109        self.eval_simple_with_paths(code, vec![], vec![]).await
110    }
111
112    /// Evaluate Lua code without LLM bridge, with optional extra package paths
113    /// and variant pkgs.
114    ///
115    /// When both `extra_lib_paths` and `variant_pkgs` are empty, reuses the
116    /// shared VM (cheap). When either is non-empty, spawns a dedicated VM so
117    /// the extra resolvers are active (slightly more expensive, but `pkg_list`
118    /// is the only caller and it is low-frequency).
119    ///
120    /// The fast path does not register `alc.*` bridge primitives, so the
121    /// `state_store` / `card_store` / `scenarios_dir` handles that
122    /// [`Self::start_session`] requires are not threaded through here —
123    /// callers that need them go through `start_session`.
124    pub async fn eval_simple_with_paths(
125        &self,
126        code: String,
127        extra_lib_paths: Vec<PathBuf>,
128        variant_pkgs: Vec<VariantPkg>,
129    ) -> Result<serde_json::Value, String> {
130        if extra_lib_paths.is_empty() && variant_pkgs.is_empty() {
131            // Fast path: reuse the long-lived shared VM.
132            let task = self.isle.spawn_exec(move |lua| {
133                let result: mlua::Value = lua
134                    .load(&code)
135                    .eval()
136                    .map_err(|e| IsleError::Lua(e.to_string()))?;
137                let json: serde_json::Value = lua
138                    .from_value(result)
139                    .map_err(|e| IsleError::Lua(e.to_string()))?;
140                serde_json::to_string(&json)
141                    .map_err(|e| IsleError::Lua(format!("JSON serialize: {e}")))
142            });
143            let json_str = task.await.map_err(|e| e.to_string())?;
144            return serde_json::from_str(&json_str).map_err(|e| format!("JSON parse: {e}"));
145        }
146
147        // Slow path: spawn a dedicated VM with extra resolvers prepended.
148        let mut effective = extra_lib_paths;
149        effective.extend(self.lib_paths.iter().cloned());
150
151        let (tmp_isle, _tmp_driver) = AsyncIsle::spawn(move |lua| {
152            let mut reg = Registry::new();
153            // Variant pkgs first so alc.local.toml overrides win over global.
154            register_variant_pkgs(&mut reg, &variant_pkgs);
155            for path in &effective {
156                if let Some(resolver) = make_resolver(path) {
157                    reg.add(resolver);
158                }
159            }
160            reg.install(lua)?;
161            Ok(())
162        })
163        .await
164        .map_err(|e| format!("eval_simple VM spawn failed: {e}"))?;
165
166        let task = tmp_isle.spawn_exec(move |lua| {
167            let result: mlua::Value = lua
168                .load(&code)
169                .eval()
170                .map_err(|e| IsleError::Lua(e.to_string()))?;
171            let json: serde_json::Value = lua
172                .from_value(result)
173                .map_err(|e| IsleError::Lua(e.to_string()))?;
174            serde_json::to_string(&json).map_err(|e| IsleError::Lua(format!("JSON serialize: {e}")))
175        });
176
177        let json_str = task.await.map_err(|e| e.to_string())?;
178        serde_json::from_str(&json_str).map_err(|e| format!("JSON parse: {e}"))
179    }
180
181    /// Start a new Lua execution session on a **dedicated VM**.
182    ///
183    /// Each session gets its own Lua VM (OS thread + mlua instance) so
184    /// concurrent sessions cannot interfere with each other's globals.
185    /// The VM is cleaned up automatically when the session completes or
186    /// is abandoned (all senders drop → channel closes → thread exits).
187    ///
188    /// `extra_lib_paths` are prepended to `self.lib_paths` so project-local
189    /// packages take precedence over the global package directory.
190    /// `variant_pkgs` come from `alc.local.toml` and override both layers
191    /// (registered at the highest priority).
192    ///
193    /// `dirs` is resolved by the service layer (typically from
194    /// `AppConfig.app_dir()`) so the engine crate never touches HOME. Its
195    /// fields flow through [`bridge::BridgeConfig`] to back `alc.state.*` /
196    /// `alc.card.*` / `alc._dirs.scenarios` and the `nn` feature's
197    /// `alc.nn.save/load` store. An empty [`SessionDirs::nn_dir`] disables
198    /// the store install (save/load then error).
199    #[allow(clippy::too_many_arguments)]
200    pub async fn start_session(
201        &self,
202        code: String,
203        ctx: serde_json::Value,
204        extra_lib_paths: Vec<PathBuf>,
205        variant_pkgs: Vec<VariantPkg>,
206        dirs: SessionDirs,
207        card_run_enabled: bool,
208    ) -> Result<Session, String> {
209        let SessionDirs {
210            state_store,
211            card_store,
212            scenarios_dir,
213            nn_dir,
214        } = dirs;
215        let spec = ExecutionSpec::new(code, ctx);
216        let metrics = ExecutionMetrics::new();
217
218        // Extract and apply budget from ctx.budget
219        if let Some(budget) = Budget::from_ctx(&spec.ctx) {
220            metrics.set_budget(budget);
221        }
222
223        let (llm_tx, llm_rx) = tokio::sync::mpsc::channel::<LlmRequest>(16);
224
225        // Build effective lib_paths: extra (project-local) first, then defaults.
226        // Priority: variant_pkgs > extra_lib_paths > self.lib_paths
227        // (ALC_PACKAGES_PATH + global default).
228        let mut effective = extra_lib_paths;
229        effective.extend(self.lib_paths.iter().cloned());
230
231        // Obtain the log-capture sink before moving `metrics` into BridgeConfig.
232        // The same Arc is shared with Session so snapshot() can read recent_logs.
233        let log_sink = metrics.log_sink_handle();
234
235        // Phase 2-D: register this session's LogSink with the process-wide
236        // LogSinkCardSubscriber so `CardEvent`s land as `LogEntry`s alongside
237        // `alc.log()` / `print()` output. The guard's `Drop` unregisters when
238        // the session ends.
239        let log_sink_registration = crate::card::register_log_sink(log_sink.clone());
240
241        let bridge_config = bridge::BridgeConfig {
242            llm_tx: Some(llm_tx),
243            ns: spec.namespace.clone(),
244            custom_metrics: metrics.custom_metrics_handle(),
245            stats: metrics.stats_handle(),
246            budget: metrics.budget_handle(),
247            progress: metrics.progress_handle(),
248            lib_paths: effective.clone(), // fork child VMs inherit project paths
249            variant_pkgs: variant_pkgs.clone(), // fork child VMs inherit variant overrides
250            state_store,
251            card_store,
252            card_run_enabled,
253            scenarios_dir,
254            nn_dir,
255            log_sink: Some(log_sink.clone()),
256        };
257        let lua_ctx = spec.ctx.clone();
258        let lua_code = spec.code.clone();
259
260        // 1. Spawn a dedicated VM for this session.
261        let (session_isle, session_driver) = AsyncIsle::spawn(move |lua| {
262            let mut reg = Registry::new();
263            // Variant pkgs first so alc.local.toml overrides win over global.
264            register_variant_pkgs(&mut reg, &variant_pkgs);
265            for path in &effective {
266                if let Some(resolver) = make_resolver(path) {
267                    reg.add(resolver);
268                }
269            }
270            reg.install(lua)?;
271            Ok(())
272        })
273        .await
274        .map_err(|e| format!("Session VM spawn failed: {e}"))?;
275
276        // 2. Setup: register alc.* StdLib, set ctx, load prelude.
277        //    Safe to set globals — this VM is exclusively ours.
278        session_isle
279            .exec(move |lua| {
280                let alc_table = lua.create_table()?;
281                bridge::register(lua, &alc_table, bridge_config)?;
282                lua.globals().set("alc", alc_table)?;
283
284                // Register vendored evalframe (std shim + package.preload) so
285                // `require("evalframe")` from the prelude or user code succeeds
286                // without depending on ~/.algocline/packages/evalframe/.
287                bridge::register_evalframe(lua)
288                    .map_err(|e| IsleError::Lua(format!("register_evalframe failed: {e}")))?;
289
290                let ctx_value = lua.to_value(&lua_ctx)?;
291                lua.globals().set("ctx", ctx_value)?;
292
293                lua.load(PRELUDE)
294                    .exec()
295                    .map_err(|e| IsleError::Lua(format!("Prelude load failed: {e}")))?;
296
297                // Note: `print()` redirect is handled by `bridge::register` via
298                // `data::register_print` when `BridgeConfig::log_sink` is Some.
299                // That implementation routes to both tracing (alc.lua.print) and
300                // the per-session LogSink ring buffer, keeping stdout clean for
301                // the rmcp JSON-RPC stdio transport.
302                //
303                // `io.write` is intentionally left unchanged — scripts that
304                // explicitly target stdout/stderr can still use it.
305
306                // No need to clear package.loaded — fresh VM.
307
308                Ok("ok".to_string())
309            })
310            .await
311            .map_err(|e| format!("Session setup failed: {e}"))?;
312
313        // 3. Execute user code as a coroutine on the session VM.
314        let wrapped_code = format!("return alc.json_encode((function()\n{lua_code}\nend)())");
315        let exec_task = session_isle.spawn_coroutine_eval(&wrapped_code);
316
317        // Handle no longer needed — all requests have been sent.
318        // The driver keeps the channel alive until the session completes.
319        drop(session_isle);
320
321        let mut session = Session::new(llm_rx, exec_task, metrics, session_driver);
322        session.attach_log_sink_registration(Some(log_sink_registration));
323        Ok(session)
324    }
325
326    /// Like [`start_session`] but registers `alc.env` inside the setup closure
327    /// with a pre-built, frozen env snapshot.
328    ///
329    /// The `env_map` must be fully populated from all sources (inject / dotenv /
330    /// os.env) **before** this call — the TIME boundary freeze happens at the
331    /// call site (i.e. `alc_run` invocation in the service layer).
332    ///
333    /// Existing `start_session` call sites remain untouched (out-of-scope wave
334    /// zero design).  Service layer code that resolves env calls this wrapper
335    /// instead.
336    #[allow(clippy::too_many_arguments)]
337    pub async fn start_session_with_env(
338        &self,
339        env_map: Arc<HashMap<String, String>>,
340        code: String,
341        ctx: serde_json::Value,
342        extra_lib_paths: Vec<PathBuf>,
343        variant_pkgs: Vec<VariantPkg>,
344        dirs: SessionDirs,
345        card_run_enabled: bool,
346    ) -> Result<Session, String> {
347        let SessionDirs {
348            state_store,
349            card_store,
350            scenarios_dir,
351            nn_dir,
352        } = dirs;
353        let spec = ExecutionSpec::new(code, ctx);
354        let metrics = ExecutionMetrics::new();
355
356        if let Some(budget) = Budget::from_ctx(&spec.ctx) {
357            metrics.set_budget(budget);
358        }
359
360        let (llm_tx, llm_rx) = tokio::sync::mpsc::channel::<LlmRequest>(16);
361
362        let mut effective = extra_lib_paths;
363        effective.extend(self.lib_paths.iter().cloned());
364
365        let log_sink = metrics.log_sink_handle();
366
367        // Phase 2-D: register per-session LogSink on the card subscriber bus,
368        // parallel to `start_session`. See that method's comment for details.
369        let log_sink_registration = crate::card::register_log_sink(log_sink.clone());
370
371        let bridge_config = bridge::BridgeConfig {
372            llm_tx: Some(llm_tx),
373            ns: spec.namespace.clone(),
374            custom_metrics: metrics.custom_metrics_handle(),
375            stats: metrics.stats_handle(),
376            budget: metrics.budget_handle(),
377            progress: metrics.progress_handle(),
378            lib_paths: effective.clone(),
379            variant_pkgs: variant_pkgs.clone(),
380            state_store,
381            card_store,
382            card_run_enabled,
383            scenarios_dir,
384            nn_dir,
385            log_sink: Some(log_sink.clone()),
386        };
387        let lua_ctx = spec.ctx.clone();
388        let lua_code = spec.code.clone();
389
390        let (session_isle, session_driver) = AsyncIsle::spawn(move |lua| {
391            let mut reg = Registry::new();
392            register_variant_pkgs(&mut reg, &variant_pkgs);
393            for path in &effective {
394                if let Some(resolver) = make_resolver(path) {
395                    reg.add(resolver);
396                }
397            }
398            reg.install(lua)?;
399            Ok(())
400        })
401        .await
402        .map_err(|e| format!("Session VM spawn failed: {e}"))?;
403
404        session_isle
405            .exec(move |lua| {
406                let alc_table = lua.create_table()?;
407                bridge::register(lua, &alc_table, bridge_config)?;
408                // Register alc.env with the frozen snapshot (TIME boundary: fully
409                // populated before this call, never re-read from OS after this point).
410                bridge::register_env(lua, &alc_table, env_map)
411                    .map_err(|e| IsleError::Lua(e.to_string()))?;
412                lua.globals().set("alc", alc_table)?;
413
414                // Register vendored evalframe (std shim + package.preload).
415                bridge::register_evalframe(lua)
416                    .map_err(|e| IsleError::Lua(format!("register_evalframe failed: {e}")))?;
417
418                let ctx_value = lua.to_value(&lua_ctx)?;
419                lua.globals().set("ctx", ctx_value)?;
420
421                lua.load(PRELUDE)
422                    .exec()
423                    .map_err(|e| IsleError::Lua(format!("Prelude load failed: {e}")))?;
424
425                Ok("ok".to_string())
426            })
427            .await
428            .map_err(|e| format!("Session setup failed: {e}"))?;
429
430        let wrapped_code = format!("return alc.json_encode((function()\n{lua_code}\nend)())");
431        let exec_task = session_isle.spawn_coroutine_eval(&wrapped_code);
432
433        drop(session_isle);
434
435        let mut session = Session::new(llm_rx, exec_task, metrics, session_driver);
436        session.attach_log_sink_registration(Some(log_sink_registration));
437        Ok(session)
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use std::fs;
445
446    /// Create a temporary package directory with the given name and `init.lua` content.
447    fn make_pkg_dir(parent: &std::path::Path, pkg_name: &str, init_lua: &str) -> PathBuf {
448        let pkg_dir = parent.join(pkg_name);
449        fs::create_dir_all(&pkg_dir).unwrap();
450        fs::write(pkg_dir.join("init.lua"), init_lua).unwrap();
451        parent.to_path_buf()
452    }
453
454    /// `extra_lib_paths=vec![]` — eval_simple must work as before.
455    #[tokio::test]
456    async fn no_extra_lib_paths_eval_simple() {
457        let executor = Executor::new(vec![]).await.unwrap();
458        let result = executor.eval_simple("return 42".to_string()).await.unwrap();
459        assert_eq!(result, serde_json::json!(42));
460    }
461
462    /// `eval_simple_with_paths` with a project-local package.
463    ///
464    /// Creates a temp dir with `test_pkg/init.lua` returning `{value = 99}`,
465    /// then verifies `require("test_pkg").value` == 99 via the extra resolver.
466    #[tokio::test]
467    async fn extra_lib_paths_reachable_via_eval_simple_with_paths() {
468        let tmp = tempfile::tempdir().unwrap();
469        let pkg_root = make_pkg_dir(tmp.path(), "test_pkg", "return { value = 99 }");
470
471        let executor = Executor::new(vec![]).await.unwrap();
472        let code = r#"
473            local pkg = require("test_pkg")
474            return pkg.value
475        "#
476        .to_string();
477
478        let result = executor
479            .eval_simple_with_paths(code, vec![pkg_root], vec![])
480            .await
481            .unwrap();
482
483        assert_eq!(result, serde_json::json!(99));
484    }
485
486    /// Variant pkg with a non-matching directory name resolves via
487    /// `VariantRootResolver` + `PrefixResolver`.
488    #[tokio::test]
489    async fn variant_pkg_resolves_root_and_submodule() {
490        let tmp = tempfile::tempdir().unwrap();
491        // pkg dir name (`physical-dir`) intentionally differs from the
492        // require name (`logical_name`) — variant scope must support this.
493        let pkg_dir = tmp.path().join("physical-dir");
494        fs::create_dir_all(&pkg_dir).unwrap();
495        fs::write(
496            pkg_dir.join("init.lua"),
497            "return { greet = function(n) return 'hi-' .. n end, sub = require('logical_name.sub') }",
498        )
499        .unwrap();
500        fs::write(pkg_dir.join("sub.lua"), "return { value = 7 }").unwrap();
501
502        let executor = Executor::new(vec![]).await.unwrap();
503        let code = r#"
504            local pkg = require("logical_name")
505            return { msg = pkg.greet("there"), sub_value = pkg.sub.value }
506        "#
507        .to_string();
508
509        let result = executor
510            .eval_simple_with_paths(code, vec![], vec![VariantPkg::new("logical_name", pkg_dir)])
511            .await
512            .unwrap();
513
514        assert_eq!(result["msg"], serde_json::json!("hi-there"));
515        assert_eq!(result["sub_value"], serde_json::json!(7));
516    }
517
518    /// Variant pkg overrides a same-name global pkg (priority: variant > global).
519    #[tokio::test]
520    async fn variant_pkg_overrides_global_same_name() {
521        let global_tmp = tempfile::tempdir().unwrap();
522        let variant_tmp = tempfile::tempdir().unwrap();
523
524        // Global: my_pkg returns 1
525        make_pkg_dir(global_tmp.path(), "my_pkg", "return { value = 1 }");
526        // Variant: my_pkg returns 2 — must win
527        let variant_dir = variant_tmp.path().join("my_pkg");
528        fs::create_dir_all(&variant_dir).unwrap();
529        fs::write(variant_dir.join("init.lua"), "return { value = 2 }").unwrap();
530
531        let executor = Executor::new(vec![global_tmp.path().to_path_buf()])
532            .await
533            .unwrap();
534
535        let code = r#"
536            local pkg = require("my_pkg")
537            return pkg.value
538        "#
539        .to_string();
540
541        let result = executor
542            .eval_simple_with_paths(code, vec![], vec![VariantPkg::new("my_pkg", variant_dir)])
543            .await
544            .unwrap();
545
546        assert_eq!(result, serde_json::json!(2));
547    }
548
549    /// When `extra_lib_paths` has a pkg with the same name as one in global paths,
550    /// the extra one takes priority (it is prepended).
551    #[tokio::test]
552    async fn extra_lib_paths_priority_over_default() {
553        let global_tmp = tempfile::tempdir().unwrap();
554        let extra_tmp = tempfile::tempdir().unwrap();
555
556        // Global: test_pkg returns 1
557        make_pkg_dir(global_tmp.path(), "test_pkg", "return { value = 1 }");
558        // Extra (project-local): test_pkg returns 2
559        let extra_root = make_pkg_dir(extra_tmp.path(), "test_pkg", "return { value = 2 }");
560
561        // Executor has global as its lib_paths.
562        let executor = Executor::new(vec![global_tmp.path().to_path_buf()])
563            .await
564            .unwrap();
565
566        let code = r#"
567            local pkg = require("test_pkg")
568            return pkg.value
569        "#
570        .to_string();
571
572        let result = executor
573            .eval_simple_with_paths(code, vec![extra_root], vec![])
574            .await
575            .unwrap();
576
577        // extra (2) must win over global (1)
578        assert_eq!(result, serde_json::json!(2));
579    }
580}