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::path::PathBuf;
21
22use algocline_core::{ExecutionMetrics, ExecutionSpec};
23use mlua::LuaSerdeExt;
24use mlua_isle::{AsyncIsle, AsyncIsleDriver, IsleError};
25use mlua_pkg::{resolvers::FsResolver, Registry};
26
27use crate::bridge;
28use crate::llm_bridge::LlmRequest;
29use crate::session::Session;
30
31/// Layer 1: Prelude combinators (map, reduce, vote, filter).
32/// Embedded at compile time and loaded into every session.
33const PRELUDE: &str = include_str!("prelude.lua");
34
35/// Lua execution engine.
36///
37/// Holds a **shared VM** for lightweight stateless operations (`eval_simple`)
38/// and spawns **per-session VMs** for coroutine-based execution (`start_session`).
39///
40/// Per-session VMs eliminate global namespace pollution between concurrent
41/// sessions — each session's `alc`, `ctx`, and `package.loaded` are fully
42/// isolated.
43pub struct Executor {
44 /// Shared VM for eval_simple (stateless, no session globals).
45 isle: AsyncIsle,
46 _driver: AsyncIsleDriver,
47 /// Package resolver paths, cloned into each per-session VM.
48 lib_paths: Vec<PathBuf>,
49}
50
51impl Executor {
52 pub async fn new(lib_paths: Vec<PathBuf>) -> anyhow::Result<Self> {
53 let paths_for_shared = lib_paths.clone();
54 let (isle, driver) = AsyncIsle::spawn(move |lua| {
55 let mut reg = Registry::new();
56 for path in &paths_for_shared {
57 if let Ok(resolver) = FsResolver::new(path) {
58 reg.add(resolver);
59 }
60 }
61 reg.install(lua)?;
62 Ok(())
63 })
64 .await?;
65
66 Ok(Self {
67 isle,
68 _driver: driver,
69 lib_paths,
70 })
71 }
72
73 /// Evaluate Lua code without LLM bridge. For lightweight operations
74 /// like reading package metadata.
75 pub async fn eval_simple(&self, code: String) -> Result<serde_json::Value, String> {
76 let task = self.isle.spawn_exec(move |lua| {
77 let result: mlua::Value = lua
78 .load(&code)
79 .eval()
80 .map_err(|e| IsleError::Lua(e.to_string()))?;
81 let json: serde_json::Value = lua
82 .from_value(result)
83 .map_err(|e| IsleError::Lua(e.to_string()))?;
84 serde_json::to_string(&json).map_err(|e| IsleError::Lua(format!("JSON serialize: {e}")))
85 });
86
87 let json_str = task.await.map_err(|e| e.to_string())?;
88 serde_json::from_str(&json_str).map_err(|e| format!("JSON parse: {e}"))
89 }
90
91 /// Start a new Lua execution session on a **dedicated VM**.
92 ///
93 /// Each session gets its own Lua VM (OS thread + mlua instance) so
94 /// concurrent sessions cannot interfere with each other's globals.
95 /// The VM is cleaned up automatically when the session completes or
96 /// is abandoned (all senders drop → channel closes → thread exits).
97 pub async fn start_session(
98 &self,
99 code: String,
100 ctx: serde_json::Value,
101 ) -> Result<Session, String> {
102 let spec = ExecutionSpec::new(code, ctx);
103 let metrics = ExecutionMetrics::new();
104 let custom_handle = metrics.custom_handle();
105
106 let (llm_tx, llm_rx) = tokio::sync::mpsc::channel::<LlmRequest>(16);
107
108 let ns = spec.namespace.clone();
109 let lua_ctx = spec.ctx.clone();
110 let lua_code = spec.code.clone();
111
112 // 1. Spawn a dedicated VM for this session.
113 let lib_paths = self.lib_paths.clone();
114 let (session_isle, session_driver) = AsyncIsle::spawn(move |lua| {
115 let mut reg = Registry::new();
116 for path in &lib_paths {
117 if let Ok(resolver) = FsResolver::new(path) {
118 reg.add(resolver);
119 }
120 }
121 reg.install(lua)?;
122 Ok(())
123 })
124 .await
125 .map_err(|e| format!("Session VM spawn failed: {e}"))?;
126
127 // 2. Setup: register alc.* StdLib, set ctx, load prelude.
128 // Safe to set globals — this VM is exclusively ours.
129 session_isle
130 .exec(move |lua| {
131 let alc_table = lua.create_table()?;
132 bridge::register(lua, &alc_table, Some(llm_tx), ns, custom_handle)?;
133 lua.globals().set("alc", alc_table)?;
134
135 let ctx_value = lua.to_value(&lua_ctx)?;
136 lua.globals().set("ctx", ctx_value)?;
137
138 lua.load(PRELUDE)
139 .exec()
140 .map_err(|e| IsleError::Lua(format!("Prelude load failed: {e}")))?;
141
142 // No need to clear package.loaded — fresh VM.
143
144 Ok("ok".to_string())
145 })
146 .await
147 .map_err(|e| format!("Session setup failed: {e}"))?;
148
149 // 3. Execute user code as a coroutine on the session VM.
150 let wrapped_code = format!("return alc.json_encode((function()\n{lua_code}\nend)())");
151 let exec_task = session_isle.spawn_coroutine_eval(&wrapped_code);
152
153 // Handle no longer needed — all requests have been sent.
154 // The driver keeps the channel alive until the session completes.
155 drop(session_isle);
156
157 Ok(Session::new(llm_rx, exec_task, metrics, session_driver))
158 }
159}