Skip to main content

luft_runtime/
sandbox.rs

1//! Sandboxed mlua runtime + SDK bridge (code-design §4).
2//!
3//! The Runtime executes Lua orchestration scripts using mlua. It provides the
4//! SDK primitives that bridge to the scheduler:
5//!
6//! - `agent(opts)`               — run a single agent
7//! - `parallel(items, mapFn)`    — barrier fan-out, results in input order
8//! - `pipeline(items, stages)`   — non-barrier streaming stages (M2)
9
10//! - `workflow(path, args?)`     — nested sub-workflow (M6)
11//! - `phase(name, planned?)`     — progress grouping, returns phase id
12//! - `phase_begin(name, planned?)` — begin a structural phase span (push)
13//! - `phase_end(span_id?)`        — end the current structural phase span (pop)
14//! - `log(msg, level?)`          — structured log event
15//! - `budget(time_ms?, rounds?)` — runtime limits hint
16//! - `report(value)`             — final workflow output
17//! - `json.encode/decode`        — (de)serialization helpers
18//!
19//! Each primitive is registered by a `register_*_sdk` function in the
20//! [`crate::sdk`] (and
21
22//! [`crate::pipeline`]) modules; this file only owns the VM lifecycle
23//! and the registration dispatch in [`register_sdk`].
24//!
25//! The SDK functions block on the async scheduler through a captured
26//! [`tokio::runtime::Handle`]. Because `block_on` panics inside an async worker
27//! thread, the caller MUST drive `Runtime::execute` from a blocking context
28//! (e.g. `tokio::task::spawn_blocking`); see `cli::run`.
29//!
30//! When a [`JournalStore`] is provided, `agent`/`parallel` check for cached
31//! results before submitting to the scheduler (M1 resume support) and record
32//! their outputs back into the journal keyed by cache key.
33
34use luft_core::contract::backend::RunContext;
35use luft_core::contract::event::{AgentEvent, PlanPhase};
36use luft_core::journal::JournalStore;
37use luft_core::Scheduler;
38use crate::error::{ExecLimits, ScriptError};
39use crate::sdk::convert::serde_json_to_lua;
40use crate::sdk::SdkContext;
41use crate::{pipeline, sdk};
42use mlua::{Lua, Table, Value};
43use std::sync::{Arc, Mutex};
44use tokio::runtime::Handle;
45
46/// The main runtime structure that executes Lua scripts.
47///
48/// All shared dependencies (scheduler, run context, journal, tokio handle) are
49/// captured by the SDK closures during construction; the struct itself only
50/// needs to retain the VM and the report sink.
51pub struct Runtime {
52    lua: Lua,
53    report_sink: Arc<Mutex<Option<serde_json::Value>>>,
54    events: tokio::sync::broadcast::Sender<AgentEvent>,
55    run_id: luft_core::contract::ids::RunId,
56}
57
58impl Runtime {
59    /// Create a new Runtime with sandbox applied.
60    ///
61    /// `handle` is the tokio runtime handle used by SDK primitives to block on
62    /// the async scheduler. It is captured here (in async context) so it can be
63    /// used later from the blocking execution thread.
64    pub fn new(
65        scheduler: Arc<Scheduler>,
66        run_ctx: RunContext,
67        args: serde_json::Value,
68        _limits: ExecLimits,
69        journal: Option<Arc<JournalStore>>,
70        handle: Handle,
71    ) -> Result<Self, ScriptError> {
72        tracing::info!(run_id = %run_ctx.run_id, "creating runtime");
73        let lua = Lua::new();
74        apply_sandbox(&lua)?;
75
76        let report_sink = Arc::new(Mutex::new(None));
77
78        // Set up `args` and `ctx` globals.
79        let args_table = serde_json_to_lua(&lua, args)?;
80        lua.globals().set("args", args_table)?;
81        let ctx = lua.create_table()?;
82        ctx.set("run_id", run_ctx.run_id.to_string())?;
83        lua.globals().set("ctx", ctx)?;
84
85        let cx = SdkContext::new(run_ctx, scheduler, report_sink.clone(), journal, handle);
86        let events = cx.events();
87        let run_id = cx.run_id();
88        register_sdk(&lua, &cx)?;
89        tracing::debug!(run_id = %run_id, "SDK primitives registered");
90
91        Ok(Self {
92            lua,
93            report_sink,
94            events,
95            run_id,
96        })
97    }
98
99    /// Execute the script and return the report value (if any).
100    ///
101    /// The script MUST declare a `meta = { ... }` table at the top level and a
102    /// `function main() ... end` entry point. Execution proceeds in two phases:
103    ///
104    /// 1. **Top-level exec**: runs all assignments and function definitions
105    ///    (`meta = {...}`, schema locals, `function main() ... end`). This is
106    ///    safe — no agent/phase calls happen here.
107    /// 2. **Extract meta**: reads the `meta` global, emits a [`AgentEvent::PlanPreview`].
108    /// 3. **Call main()**: invokes the `main` function which contains the real
109    ///    orchestration logic.
110    ///
111    /// MUST be called from a blocking context (not an async worker thread),
112    /// because the SDK primitives call `Handle::block_on` internally.
113    pub fn execute(&self, script: &str) -> Result<serde_json::Value, ScriptError> {
114        tracing::info!("begin script execution ({} bytes)", script.len());
115        let start = std::time::Instant::now();
116
117        // Phase 1: exec top-level (meta assignment + function definitions only).
118        self.lua.load(script).exec()?;
119
120        // Phase 2: extract meta and emit PlanPreview.
121        let run_id = self.run_id;
122        if let Some(meta) = extract_meta(&self.lua)? {
123            tracing::info!(
124                phases = meta.phases.len(),
125                reasoning = %meta.reasoning,
126                "meta extracted"
127            );
128            let _ = self.events.send(AgentEvent::PlanPreview {
129                run_id,
130                reasoning: meta.reasoning.clone(),
131                phases: meta.phases.clone(),
132            });
133        } else {
134            tracing::warn!("no meta table found in script");
135        }
136
137        // Phase 3: call main().
138        let main_fn: mlua::Function = self
139            .lua
140            .globals()
141            .get("main")
142            .map_err(|_| ScriptError::MissingMain)?;
143        main_fn.call::<()>(())?;
144
145        let elapsed = start.elapsed();
146        let guard = self.report_sink.lock().unwrap();
147        let has_report = guard.is_some();
148        tracing::info!(
149            elapsed_ms = elapsed.as_millis() as u64,
150            has_report,
151            "script execution finished"
152        );
153        Ok(guard.clone().unwrap_or(serde_json::Value::Null))
154    }
155
156    /// Inject completed phase span names as the `completed_spans` Lua global,
157    /// so resume scripts can skip already-finished structural units.
158    pub fn set_completed_spans(&self, names: &[String]) -> Result<(), ScriptError> {
159        if names.is_empty() {
160            return Ok(());
161        }
162        let table = self.lua.create_table()?;
163        for name in names {
164            table.set(name.as_str(), true)?;
165        }
166        self.lua.globals().set("completed_spans", table)?;
167        Ok(())
168    }
169}
170
171/// Validates a script (syntax only) without executing it.
172pub fn validate_script(script: &str) -> Result<(), ScriptError> {
173    tracing::debug!(bytes = script.len(), "validating script syntax");
174    let lua = Lua::new();
175    lua.load(script).into_function().map(|_| ()).map_err(|e| {
176        tracing::debug!(error = %e, "script validation failed");
177        ScriptError::from(e)
178    })
179}
180
181/// Register all SDK functions as Lua globals by dispatching to each primitive's
182/// registrar. Shared dependencies travel through [`SdkContext`].
183fn register_sdk(lua: &Lua, cx: &SdkContext) -> mlua::Result<()> {
184    sdk::agent::register_agent_sdk(lua, cx)?;
185    sdk::workflow::register_workflow_sdk(lua, cx)?;
186    sdk::control::register_control_sdk(lua, cx)?;
187    sdk::report::register_report_sdk(lua, cx)?;
188    pipeline::register_pipeline_sdk(lua, cx)?;
189    // converge::register_converge_sdk(lua, cx)?; // temporarily disabled
190    Ok(())
191}
192
193/// Apply sandbox restrictions to the Lua VM (blocks I/O / OS / dynamic loading).
194pub(crate) fn apply_sandbox(lua: &Lua) -> Result<(), ScriptError> {
195    tracing::debug!("applying Lua sandbox restrictions");
196    let globals = lua.globals();
197    for name in [
198        "io",
199        "os",
200        "debug",
201        "package",
202        "require",
203        "loadfile",
204        "dofile",
205        "loadstring",
206    ] {
207        let _ = globals.set(name, Value::Nil);
208    }
209    Ok(())
210}
211
212/// Extracted plan metadata from the `meta` global.
213pub struct WorkflowMeta {
214    pub reasoning: String,
215    pub phases: Vec<PlanPhase>,
216}
217
218/// Structured result of deep workflow validation.
219pub struct WorkflowValidation {
220    /// Extracted meta information (None if meta table is missing/invalid).
221    pub meta: Option<WorkflowMeta>,
222    /// Whether `main()` function is defined.
223    pub has_main: bool,
224    /// Whether `report(` appears in the script body (heuristic).
225    pub has_report_call: bool,
226    /// Whether `phase_begin()` / `phase_end()` calls are paired.
227    pub span_pairing_ok: bool,
228    /// All validation errors found.
229    pub errors: Vec<String>,
230    /// All validation warnings.
231    pub warnings: Vec<String>,
232}
233
234impl WorkflowValidation {
235    /// Returns `true` when there are zero errors.
236    pub fn is_valid(&self) -> bool {
237        self.errors.is_empty()
238    }
239}
240
241/// Read the `meta` global table from the Lua VM after top-level exec.
242///
243/// Expected shape:
244/// ```lua
245/// meta = {
246///   reasoning = "...",
247///   phases = {
248///     { label = "discover", dynamic = false },
249///     { label = "audit",     dynamic = true  },
250///   },
251/// }
252/// ```
253pub(crate) fn extract_meta(lua: &Lua) -> Result<Option<WorkflowMeta>, ScriptError> {
254    let meta_table: Table = match lua.globals().get("meta")? {
255        Value::Table(t) => t,
256        _ => return Ok(None),
257    };
258
259    let reasoning: String = meta_table
260        .get("reasoning")
261        .ok()
262        .and_then(|v: Value| match v {
263            Value::String(s) => s.to_str().ok().map(|s| s.to_string()),
264            _ => None,
265        })
266        .unwrap_or_default();
267
268    let phases_table: Table = match meta_table.get("phases")? {
269        Value::Table(t) => t,
270        _ => {
271            tracing::warn!("meta.phases is not a table, skipping");
272            return Ok(None);
273        }
274    };
275
276    let mut phases = Vec::new();
277    for pair in phases_table.pairs::<Value, Table>() {
278        let (_, t) = pair?;
279        let label: String = t
280            .get("label")
281            .ok()
282            .and_then(|v: Value| match v {
283                Value::String(s) => s.to_str().ok().map(|s| s.to_string()),
284                _ => None,
285            })
286            .unwrap_or_else(|| "<unlabeled>".to_string());
287        let dynamic: bool = t.get("dynamic").ok().unwrap_or(false);
288        let description: Option<String> = t.get("description").ok().and_then(|v: Value| match v {
289            Value::String(s) => s.to_str().ok().map(|s| s.to_string()),
290            _ => None,
291        });
292        phases.push(PlanPhase {
293            label,
294            dynamic,
295            description,
296        });
297    }
298
299    Ok(Some(WorkflowMeta { reasoning, phases }))
300}
301
302/// Heuristic check that `phase_begin()` calls are accompanied by at least one
303/// `phase_end()`. Dynamic loops render as a single text pair, so we only reject
304/// the case where there are begins but zero ends.
305pub(crate) fn check_span_pairing(script: &str) -> Result<(), String> {
306    let begin_count = script.matches("phase_begin(").count();
307    let end_count = script.matches("phase_end(").count();
308    if begin_count > 0 && end_count == 0 {
309        return Err(format!(
310            "script has {} phase_begin() call(s) but no phase_end() — spans must be paired",
311            begin_count
312        ));
313    }
314    Ok(())
315}
316
317/// Register no-op stubs for SDK globals so that top-level calls in malformed
318/// scripts (e.g. `agent()` outside `main()`) don't crash validation. These
319/// stubs accept any arguments and return an empty table (or nothing).
320fn register_validation_stubs(lua: &Lua) -> Result<(), ScriptError> {
321    let globals = lua.globals();
322
323    let empty_table = lua.create_table()?;
324    let stub_ret = lua.create_function(move |_, _: mlua::MultiValue| Ok(empty_table.clone()))?;
325    let stub_void = lua.create_function(|_, _: mlua::MultiValue| Ok(()))?;
326
327    for name in [
328        "agent",
329        "parallel",
330        "workflow",
331        "phase",
332        "phase_begin",
333        "phase_end",
334        "budget",
335    ] {
336        globals.set(name, stub_ret.clone())?;
337    }
338    globals.set("log", stub_void.clone())?;
339    globals.set("report", stub_void.clone())?;
340
341    let json = lua.create_table()?;
342    json.set("encode", stub_void.clone())?;
343    json.set("decode", stub_ret.clone())?;
344    globals.set("json", json)?;
345
346    Ok(())
347}
348
349/// Deep validation of a workflow script **without executing `main()`**.
350///
351/// Performs three layers of checks:
352/// 1. **Syntax** — `Lua::load` + `exec` the top level (safe: only `meta` assignment
353///    and function definitions run). SDK functions are stubbed so stray top-level
354///    calls don't crash validation.
355/// 2. **Structure** — verifies `meta` table exists with `reasoning` and `phases`,
356///    and that `main()` is defined.
357/// 3. **Heuristic** — checks for `report(` call and `phase_begin/phase_end` pairing.
358///
359/// Returns a [`WorkflowValidation`] regardless of semantic errors (only syntax
360/// errors or internal failures produce `Err`).
361pub fn validate_workflow(script: &str) -> Result<WorkflowValidation, ScriptError> {
362    let lua = Lua::new();
363    apply_sandbox(&lua)?;
364    register_validation_stubs(&lua)?;
365
366    // Phase 1: exec top-level (meta assignment + function definitions only).
367    lua.load(script).exec()?;
368
369    // Phase 2: extract meta.
370    let meta = extract_meta(&lua)?;
371
372    // Phase 3: check main() exists.
373    let has_main = lua.globals().get::<mlua::Function>("main").is_ok();
374
375    // Phase 4: heuristic checks.
376    let has_report_call = script.contains("report(");
377    let span_pairing_ok = check_span_pairing(script).is_ok();
378
379    let mut errors = Vec::new();
380    let warnings = Vec::new();
381
382    if meta.is_none() {
383        errors.push(
384            "no `meta` table found (expected `meta = { reasoning = \"...\", phases = {...} }`)"
385                .into(),
386        );
387    }
388    if !has_main {
389        errors.push("no `main()` function defined".into());
390    }
391    if !has_report_call {
392        errors.push("script must call `report(...)` to emit a final result".into());
393    }
394    if let Err(e) = check_span_pairing(script) {
395        errors.push(e);
396    }
397
398    Ok(WorkflowValidation {
399        meta,
400        has_main,
401        has_report_call,
402        span_pairing_ok,
403        errors,
404        warnings,
405    })
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::error::{ExecLimits, ScriptError};
412    use crate::sdk::test_support::make_sdk_context;
413    use luft_core::contract::backend::RunContext;
414    use luft_core::contract::ids::RunId;
415    use luft_core::{BackendRegistry, Scheduler, SchedulerConfig};
416    use mlua::Table;
417    use tokio_util::sync::CancellationToken;
418
419    /// Construct a `Runtime` with an empty backend registry. Useful for tests
420    /// that exercise the VM lifecycle / sandbox / validation paths but never
421    /// actually dispatch to a backend.
422    fn make_runtime() -> Runtime {
423        let registry = BackendRegistry::new();
424        let scheduler = Scheduler::new(SchedulerConfig::default(), registry, None);
425        let run_id = RunId::now_v7();
426        let (tx, _rx) = tokio::sync::broadcast::channel(16);
427        let run_ctx = RunContext {
428            run_id,
429            cancel: CancellationToken::new(),
430            events: tx,
431        };
432        scheduler.init_run_with(run_id, run_ctx.events.clone());
433        Runtime::new(
434            scheduler,
435            run_ctx,
436            serde_json::json!({}),
437            ExecLimits::default(),
438            None,
439            tokio::runtime::Handle::current(),
440        )
441        .expect("Runtime::new should succeed with an empty registry")
442    }
443
444    // -----------------------------------------------------------------------
445    // WorkflowMeta / WorkflowValidation — public surface
446    // -----------------------------------------------------------------------
447    #[test]
448    fn workflow_validation_is_valid_depends_only_on_errors() {
449        let v = WorkflowValidation {
450            meta: None,
451            has_main: false,
452            has_report_call: false,
453            span_pairing_ok: false,
454            errors: vec!["x".into()],
455            warnings: vec!["w".into()],
456        };
457        assert!(!v.is_valid(), "single error must mark validation invalid");
458
459        let v = WorkflowValidation {
460            meta: None,
461            has_main: false,
462            has_report_call: false,
463            span_pairing_ok: false,
464            errors: vec![],
465            warnings: vec!["w".into()],
466        };
467        assert!(v.is_valid(), "warnings alone must NOT mark invalid");
468    }
469
470    #[test]
471    fn workflow_meta_field_access() {
472        let meta = WorkflowMeta {
473            reasoning: "test reasoning".into(),
474            phases: vec![],
475        };
476        assert_eq!(meta.reasoning, "test reasoning");
477        assert!(meta.phases.is_empty());
478    }
479
480    // -----------------------------------------------------------------------
481    // validate_script — syntax-only validation
482    // -----------------------------------------------------------------------
483    #[test]
484    fn validate_script_accepts_arithmetic() {
485        assert!(validate_script("return 1 + 2").is_ok());
486    }
487
488    #[test]
489    fn validate_script_accepts_empty_string() {
490        assert!(validate_script("").is_ok());
491    }
492
493    #[test]
494    fn validate_script_accepts_function_definitions() {
495        assert!(validate_script("function add(a, b) return a + b end").is_ok());
496    }
497
498    #[test]
499    fn validate_script_accepts_multi_line() {
500        let script = r#"
501            local x = 10
502            local y = 20
503            return x + y
504        "#;
505        assert!(validate_script(script).is_ok());
506    }
507
508    #[test]
509    fn validate_script_rejects_unfinished_if() {
510        match validate_script("if true then").unwrap_err() {
511            ScriptError::Syntax(_) => {}
512            other => panic!("expected Syntax, got {other:?}"),
513        }
514    }
515
516    #[test]
517    fn validate_script_rejects_unclosed_table() {
518        match validate_script("local t = {1, 2, 3").unwrap_err() {
519            ScriptError::Syntax(_) => {}
520            other => panic!("expected Syntax, got {other:?}"),
521        }
522    }
523
524    #[test]
525    fn validate_script_rejects_garbage() {
526        match validate_script("~~ not lua ~~").unwrap_err() {
527            ScriptError::Syntax(_) => {}
528            other => panic!("expected Syntax, got {other:?}"),
529        }
530    }
531
532    #[test]
533    fn validate_script_rejects_operator_error() {
534        match validate_script("local x = 1 +++ 2").unwrap_err() {
535            ScriptError::Syntax(_) => {}
536            other => panic!("expected Syntax, got {other:?}"),
537        }
538    }
539
540    // -----------------------------------------------------------------------
541    // apply_sandbox — verifies each forbidden global is removed
542    // -----------------------------------------------------------------------
543    #[test]
544    fn apply_sandbox_removes_io_global() {
545        let lua = Lua::new();
546        apply_sandbox(&lua).unwrap();
547        assert!(lua.globals().get::<Value>("io").is_ok());
548        assert!(
549            matches!(lua.globals().get::<Value>("io").unwrap(), Value::Nil),
550            "io must be nil after sandbox"
551        );
552    }
553
554    #[test]
555    fn apply_sandbox_removes_os_global() {
556        let lua = Lua::new();
557        apply_sandbox(&lua).unwrap();
558        match lua.globals().get::<Value>("os").unwrap() {
559            Value::Nil => {}
560            other => panic!("os must be nil after sandbox, got {other:?}"),
561        }
562    }
563
564    #[test]
565    fn apply_sandbox_removes_debug_package_require_loadfile_dofile_loadstring() {
566        let lua = Lua::new();
567        apply_sandbox(&lua).unwrap();
568        for name in ["debug", "package", "require", "loadfile", "dofile", "loadstring"] {
569            match lua.globals().get::<Value>(name).unwrap() {
570                Value::Nil => {}
571                other => panic!("{name} must be nil after sandbox, got {other:?}"),
572            }
573        }
574    }
575
576    #[test]
577    fn apply_sandbox_preserves_safe_globals() {
578        let lua = Lua::new();
579        apply_sandbox(&lua).unwrap();
580        // math / string / table / pairs / ipairs / type / print / tostring remain.
581        assert!(lua.globals().get::<mlua::Function>("pairs").is_ok());
582        assert!(lua.globals().get::<mlua::Function>("ipairs").is_ok());
583        assert!(lua.globals().get::<mlua::Function>("type").is_ok());
584        assert!(lua.globals().get::<mlua::Function>("tostring").is_ok());
585        assert!(lua.globals().get::<mlua::Function>("tonumber").is_ok());
586    }
587
588    #[test]
589    fn apply_sandbox_is_idempotent() {
590        let lua = Lua::new();
591        apply_sandbox(&lua).unwrap();
592        apply_sandbox(&lua).unwrap();
593        apply_sandbox(&lua).unwrap();
594        // All forbidden globals still nil after multiple passes.
595        for name in ["io", "os", "debug", "package", "require"] {
596            match lua.globals().get::<Value>(name).unwrap() {
597                Value::Nil => {}
598                other => panic!("{name} must remain nil, got {other:?}"),
599            }
600        }
601    }
602
603    // -----------------------------------------------------------------------
604    // extract_meta — every branch
605    // -----------------------------------------------------------------------
606    #[test]
607    fn extract_meta_returns_none_when_global_missing() {
608        let lua = Lua::new();
609        assert!(matches!(extract_meta(&lua).unwrap(), None));
610    }
611
612    #[test]
613    fn extract_meta_returns_none_when_meta_is_not_a_table() {
614        let lua = Lua::new();
615        lua.globals().set("meta", "oops").unwrap();
616        assert!(matches!(extract_meta(&lua).unwrap(), None));
617    }
618
619    #[test]
620    fn extract_meta_returns_none_when_phases_missing() {
621        let lua = Lua::new();
622        lua.globals()
623            .set("meta", lua.create_table().unwrap())
624            .unwrap();
625        // meta table exists but no `phases` field.
626        assert!(matches!(extract_meta(&lua).unwrap(), None));
627    }
628
629    #[test]
630    fn extract_meta_with_well_formed_meta_extracts_reasoning_and_phases() {
631        let lua = Lua::new();
632        let meta = lua.create_table().unwrap();
633        meta.set("reasoning", "do thing").unwrap();
634        let phases = lua.create_table().unwrap();
635        let p0 = lua.create_table().unwrap();
636        p0.set("label", "discover").unwrap();
637        p0.set("dynamic", false).unwrap();
638        phases.set(1, p0).unwrap();
639        let p1 = lua.create_table().unwrap();
640        p1.set("label", "audit").unwrap();
641        p1.set("dynamic", true).unwrap();
642        p1.set("description", "long audit").unwrap();
643        phases.set(2, p1).unwrap();
644        meta.set("phases", phases).unwrap();
645        lua.globals().set("meta", meta).unwrap();
646
647        let extracted = extract_meta(&lua).unwrap().expect("meta must be Some");
648        assert_eq!(extracted.reasoning, "do thing");
649        assert_eq!(extracted.phases.len(), 2);
650        assert_eq!(extracted.phases[0].label, "discover");
651        assert!(!extracted.phases[0].dynamic);
652        assert_eq!(extracted.phases[1].label, "audit");
653        assert!(extracted.phases[1].dynamic);
654        assert_eq!(extracted.phases[1].description.as_deref(), Some("long audit"));
655    }
656
657    #[test]
658    fn extract_meta_phase_missing_label_falls_back_to_unlabeled() {
659        let lua = Lua::new();
660        let meta = lua.create_table().unwrap();
661        let phases = lua.create_table().unwrap();
662        let p0 = lua.create_table().unwrap();
663        phases.set(1, p0).unwrap();
664        meta.set("phases", phases).unwrap();
665        lua.globals().set("meta", meta).unwrap();
666
667        let extracted = extract_meta(&lua).unwrap().expect("meta must be Some");
668        assert_eq!(extracted.phases.len(), 1);
669        assert_eq!(extracted.phases[0].label, "<unlabeled>");
670        assert!(!extracted.phases[0].dynamic);
671    }
672
673    #[test]
674    fn extract_meta_missing_reasoning_defaults_to_empty_string() {
675        let lua = Lua::new();
676        let meta = lua.create_table().unwrap();
677        let phases = lua.create_table().unwrap();
678        meta.set("phases", phases).unwrap();
679        lua.globals().set("meta", meta).unwrap();
680
681        let extracted = extract_meta(&lua).unwrap().expect("meta must be Some");
682        assert_eq!(extracted.reasoning, "");
683    }
684
685    // -----------------------------------------------------------------------
686    // check_span_pairing — every branch
687    // -----------------------------------------------------------------------
688    #[test]
689    fn check_span_pairing_empty_script_is_ok() {
690        assert!(check_span_pairing("").is_ok());
691    }
692
693    #[test]
694    fn check_span_pairing_no_phase_calls_is_ok() {
695        let script = "function main() report({ ok = true }) end";
696        assert!(check_span_pairing(script).is_ok());
697    }
698
699    #[test]
700    fn check_span_pairing_only_ends_is_ok() {
701        // No begins → no pairing violation.
702        let script = "phase_end(1)";
703        assert!(check_span_pairing(script).is_ok());
704    }
705
706    #[test]
707    fn check_span_pairing_more_begins_than_ends_with_zero_ends_is_error() {
708        // The function only rejects scripts where begins > 0 AND ends == 0,
709        // so two begins + zero ends triggers the error, but two begins + one
710        // end is already balanced enough to pass.
711        let script = "phase_begin(\"a\") phase_begin(\"b\")";
712        let err = check_span_pairing(script).unwrap_err();
713        assert!(err.contains("phase_begin"));
714        assert!(err.contains("phase_end"));
715    }
716
717    #[test]
718    fn check_span_pairing_single_unpaired_begin_is_error() {
719        let script = "phase_begin(\"a\")";
720        let err = check_span_pairing(script).unwrap_err();
721        assert!(err.contains("1 phase_begin()"));
722    }
723
724    #[test]
725    fn check_span_pairing_extra_ends_do_not_count_as_paired_begins() {
726        // Multiple ends with zero begins is allowed (orphan ends are not rejected).
727        let script = "phase_end(1) phase_end(2)";
728        assert!(check_span_pairing(script).is_ok());
729    }
730
731    // -----------------------------------------------------------------------
732    // validate_workflow — every branch
733    // -----------------------------------------------------------------------
734    const WELL_FORMED: &str = r#"
735        meta = { reasoning = "do thing", phases = { { label = "x" } } }
736        function main() report({ ok = true }) end
737    "#;
738
739    #[test]
740    fn validate_workflow_well_formed_is_valid() {
741        let v = validate_workflow(WELL_FORMED).unwrap();
742        assert!(v.is_valid(), "errors: {:?}", v.errors);
743        assert!(v.has_main);
744        assert!(v.has_report_call);
745        assert!(v.span_pairing_ok);
746        let meta = v.meta.expect("meta must be Some for well-formed script");
747        assert_eq!(meta.reasoning, "do thing");
748        assert_eq!(meta.phases.len(), 1);
749        assert_eq!(meta.phases[0].label, "x");
750    }
751
752    #[test]
753    fn validate_workflow_missing_meta_is_error() {
754        let script = r#"
755            function main() report({ ok = true }) end
756        "#;
757        let v = validate_workflow(script).unwrap();
758        assert!(!v.is_valid());
759        assert!(v.errors.iter().any(|e| e.contains("meta")));
760    }
761
762    #[test]
763    fn validate_workflow_missing_main_is_error() {
764        let script = r#"
765            meta = { reasoning = "r", phases = {} }
766        "#;
767        let v = validate_workflow(script).unwrap();
768        assert!(!v.is_valid());
769        assert!(v.errors.iter().any(|e| e.contains("main")));
770    }
771
772    #[test]
773    fn validate_workflow_missing_report_call_is_error() {
774        let script = r#"
775            meta = { reasoning = "r", phases = {} }
776            function main() end
777        "#;
778        let v = validate_workflow(script).unwrap();
779        assert!(!v.is_valid());
780        assert!(v.errors.iter().any(|e| e.contains("report")));
781    }
782
783    #[test]
784    fn validate_workflow_unpaired_phase_begin_is_error() {
785        let script = r#"
786            meta = { reasoning = "r", phases = {} }
787            function main()
788              phase_begin("outer")
789              report({ ok = true })
790            end
791        "#;
792        let v = validate_workflow(script).unwrap();
793        assert!(!v.is_valid());
794        assert!(!v.span_pairing_ok);
795        assert!(v.errors.iter().any(|e| e.contains("phase_begin")));
796    }
797
798    #[test]
799    fn validate_workflow_syntax_error_returns_err() {
800        let result = validate_workflow("function main(");
801        assert!(result.is_err());
802        match result.err().expect("expected Err") {
803            ScriptError::Syntax(_) => {}
804            other => panic!("expected Syntax, got {other:?}"),
805        }
806    }
807
808    #[test]
809    fn validate_workflow_meta_can_be_nil_and_phases_omitted() {
810        let script = r#"
811            function main() report({}) end
812        "#;
813        let v = validate_workflow(script).unwrap();
814        assert!(!v.is_valid());
815        // Two errors: missing meta, no phases; plus main/report covered.
816        assert!(v.errors.iter().any(|e| e.contains("meta")));
817    }
818
819    // -----------------------------------------------------------------------
820    // Runtime lifecycle
821    // -----------------------------------------------------------------------
822    #[tokio::test]
823    async fn runtime_new_sets_args_and_ctx_globals() {
824        let runtime = make_runtime();
825        let args = runtime.lua.globals().get::<Table>("args").unwrap();
826        // Empty JSON object → empty Lua table.
827        assert_eq!(args.raw_len(), 0);
828        let ctx = runtime.lua.globals().get::<Table>("ctx").unwrap();
829        let run_id_str: String = ctx.get("run_id").unwrap();
830        assert!(!run_id_str.is_empty());
831    }
832
833    #[tokio::test]
834    async fn runtime_new_sets_globals_like_math_string_table() {
835        let runtime = make_runtime();
836        // Untouched Lua globals should be preserved (sandbox doesn't strip them).
837        let g = runtime.lua.globals();
838        assert!(g.get::<mlua::Function>("pairs").is_ok());
839        assert!(g.get::<Table>("math").is_ok());
840        assert!(g.get::<Table>("string").is_ok());
841        assert!(g.get::<Table>("table").is_ok());
842    }
843
844    #[tokio::test]
845    async fn runtime_new_does_not_expose_sandbox_globals() {
846        let runtime = make_runtime();
847        for forbidden in ["io", "os", "debug", "package", "require", "loadfile"] {
848            match runtime.lua.globals().get::<Value>(forbidden).unwrap() {
849                Value::Nil => {}
850                other => panic!("{forbidden} must be nil after Runtime::new, got {other:?}"),
851            }
852        }
853    }
854
855    #[tokio::test]
856    async fn runtime_set_completed_spans_empty_input_is_noop() {
857        let runtime = make_runtime();
858        // No-op for empty input: must not create the global.
859        runtime.set_completed_spans(&[]).unwrap();
860        // The global is absent (set only when non-empty), but accessing it as
861        // mlua::Value should not panic — we just confirm a non-error path.
862        let _ = runtime.lua.globals().get::<Value>("completed_spans");
863    }
864
865    #[tokio::test]
866    async fn runtime_set_completed_spans_creates_global_with_truthy_names() {
867        let runtime = make_runtime();
868        runtime
869            .set_completed_spans(&["explore".into(), "audit".into()])
870            .unwrap();
871        let t: Table = runtime
872            .lua
873            .globals()
874            .get("completed_spans")
875            .expect("completed_spans global must exist after set");
876        // Use string-key lookup instead of raw_len (which counts sequence
877        // entries, but the runtime sets string keys).
878        assert!(matches!(t.get::<bool>("explore").unwrap(), true));
879        assert!(matches!(t.get::<bool>("audit").unwrap(), true));
880        // Iterate to confirm both keys are present.
881        let mut seen = 0;
882        for _ in t.clone().pairs::<String, bool>() {
883            seen += 1;
884        }
885        assert_eq!(seen, 2);
886    }
887
888    #[tokio::test]
889    async fn runtime_execute_well_formed_script_returns_report_value() {
890        let runtime = make_runtime();
891        let script = r#"
892            meta = { reasoning = "ok", phases = { { label = "do" } } }
893            function main() report({ value = 42, kind = "unit" }) end
894        "#;
895        let report = runtime
896            .execute(script)
897            .expect("well-formed script must execute");
898        assert_eq!(report["value"], 42);
899        assert_eq!(report["kind"], "unit");
900    }
901
902    #[tokio::test]
903    async fn runtime_execute_returns_null_when_report_never_called() {
904        let runtime = make_runtime();
905        let script = r#"
906            meta = { reasoning = "ok", phases = {} }
907            function main() end
908        "#;
909        // validate_workflow would reject this, but Runtime::execute itself
910        // still runs — it should return Null when no report() is called.
911        let report = runtime
912            .execute(script)
913            .expect("script missing report() still executes; result is null");
914        assert_eq!(report, serde_json::Value::Null);
915    }
916
917    #[tokio::test]
918    async fn runtime_execute_missing_main_returns_missing_main_error() {
919        let runtime = make_runtime();
920        // Top-level-only assigns meta but never defines main().
921        let script = r#"
922            meta = { reasoning = "ok", phases = {} }
923        "#;
924        match runtime.execute(script).unwrap_err() {
925            ScriptError::MissingMain => {}
926            other => panic!("expected MissingMain, got {other:?}"),
927        }
928    }
929
930    #[tokio::test]
931    async fn runtime_execute_syntax_error_surfaces_syntax_variant() {
932        let runtime = make_runtime();
933        let script = "function main( report({}) end"; // unclosed paren
934        match runtime.execute(script).unwrap_err() {
935            ScriptError::Syntax(_) => {}
936            other => panic!("expected Syntax, got {other:?}"),
937        }
938    }
939
940    #[tokio::test]
941    async fn runtime_execute_meta_table_emits_plan_preview_event() {
942        let runtime = make_runtime();
943        // Subscribe *after* construction so we only see events emitted during execute().
944        let mut rx = runtime.events.subscribe();
945        let script = r#"
946            meta = { reasoning = "p", phases = { { label = "L" } } }
947            function main() report({ ok = true }) end
948        "#;
949        runtime.execute(script).unwrap();
950        let ev = rx.try_recv().expect("PlanPreview must be emitted");
951        match ev {
952            AgentEvent::PlanPreview {
953                reasoning,
954                phases,
955                ..
956            } => {
957                assert_eq!(reasoning, "p");
958                assert_eq!(phases.len(), 1);
959                assert_eq!(phases[0].label, "L");
960            }
961            other => panic!("expected PlanPreview, got {other:?}"),
962        }
963    }
964
965    // -----------------------------------------------------------------------
966    // Public-API surface compile-time checks
967    // -----------------------------------------------------------------------
968    #[tokio::test]
969    async fn public_api_surface() {
970        // Touch each public type/function once so a future rename / visibility
971        // change is caught at compile time.
972        let _: fn(&str) -> Result<WorkflowValidation, ScriptError> = validate_workflow;
973        let _: fn(&str) -> Result<(), ScriptError> = validate_script;
974        // WorkflowMeta / WorkflowValidation are plain structs — make sure the
975        // field shapes still match the call sites in the rest of the runtime.
976        let _m = WorkflowMeta {
977            reasoning: "".into(),
978            phases: vec![],
979        };
980        let _v = WorkflowValidation {
981            meta: None,
982            has_main: false,
983            has_report_call: false,
984            span_pairing_ok: true,
985            errors: vec![],
986            warnings: vec![],
987        };
988        // Public methods on Runtime stay callable.
989        let rt = make_runtime();
990        let _: Result<(), ScriptError> = rt.set_completed_spans(&["a".into()]);
991    }
992
993    // -----------------------------------------------------------------------
994    // Helper-touching the SdkContext helper to ensure test_support compiles
995    // -----------------------------------------------------------------------
996    #[tokio::test]
997    async fn test_support_helper_builds_sdk_context() {
998        let _cx = make_sdk_context();
999    }
1000}