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 crate::error::{ExecLimits, ScriptError};
35use crate::sdk::convert::serde_json_to_lua;
36use crate::sdk::SdkContext;
37use crate::{pipeline, sdk};
38use luft_core::contract::backend::RunContext;
39use luft_core::contract::event::{AgentEvent, PlanPhase};
40use luft_core::journal::JournalStore;
41use luft_core::Scheduler;
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 [
569            "debug",
570            "package",
571            "require",
572            "loadfile",
573            "dofile",
574            "loadstring",
575        ] {
576            match lua.globals().get::<Value>(name).unwrap() {
577                Value::Nil => {}
578                other => panic!("{name} must be nil after sandbox, got {other:?}"),
579            }
580        }
581    }
582
583    #[test]
584    fn apply_sandbox_preserves_safe_globals() {
585        let lua = Lua::new();
586        apply_sandbox(&lua).unwrap();
587        // math / string / table / pairs / ipairs / type / print / tostring remain.
588        assert!(lua.globals().get::<mlua::Function>("pairs").is_ok());
589        assert!(lua.globals().get::<mlua::Function>("ipairs").is_ok());
590        assert!(lua.globals().get::<mlua::Function>("type").is_ok());
591        assert!(lua.globals().get::<mlua::Function>("tostring").is_ok());
592        assert!(lua.globals().get::<mlua::Function>("tonumber").is_ok());
593    }
594
595    #[test]
596    fn apply_sandbox_is_idempotent() {
597        let lua = Lua::new();
598        apply_sandbox(&lua).unwrap();
599        apply_sandbox(&lua).unwrap();
600        apply_sandbox(&lua).unwrap();
601        // All forbidden globals still nil after multiple passes.
602        for name in ["io", "os", "debug", "package", "require"] {
603            match lua.globals().get::<Value>(name).unwrap() {
604                Value::Nil => {}
605                other => panic!("{name} must remain nil, got {other:?}"),
606            }
607        }
608    }
609
610    // -----------------------------------------------------------------------
611    // extract_meta — every branch
612    // -----------------------------------------------------------------------
613    #[test]
614    fn extract_meta_returns_none_when_global_missing() {
615        let lua = Lua::new();
616        assert!(matches!(extract_meta(&lua).unwrap(), None));
617    }
618
619    #[test]
620    fn extract_meta_returns_none_when_meta_is_not_a_table() {
621        let lua = Lua::new();
622        lua.globals().set("meta", "oops").unwrap();
623        assert!(matches!(extract_meta(&lua).unwrap(), None));
624    }
625
626    #[test]
627    fn extract_meta_returns_none_when_phases_missing() {
628        let lua = Lua::new();
629        lua.globals()
630            .set("meta", lua.create_table().unwrap())
631            .unwrap();
632        // meta table exists but no `phases` field.
633        assert!(matches!(extract_meta(&lua).unwrap(), None));
634    }
635
636    #[test]
637    fn extract_meta_with_well_formed_meta_extracts_reasoning_and_phases() {
638        let lua = Lua::new();
639        let meta = lua.create_table().unwrap();
640        meta.set("reasoning", "do thing").unwrap();
641        let phases = lua.create_table().unwrap();
642        let p0 = lua.create_table().unwrap();
643        p0.set("label", "discover").unwrap();
644        p0.set("dynamic", false).unwrap();
645        phases.set(1, p0).unwrap();
646        let p1 = lua.create_table().unwrap();
647        p1.set("label", "audit").unwrap();
648        p1.set("dynamic", true).unwrap();
649        p1.set("description", "long audit").unwrap();
650        phases.set(2, p1).unwrap();
651        meta.set("phases", phases).unwrap();
652        lua.globals().set("meta", meta).unwrap();
653
654        let extracted = extract_meta(&lua).unwrap().expect("meta must be Some");
655        assert_eq!(extracted.reasoning, "do thing");
656        assert_eq!(extracted.phases.len(), 2);
657        assert_eq!(extracted.phases[0].label, "discover");
658        assert!(!extracted.phases[0].dynamic);
659        assert_eq!(extracted.phases[1].label, "audit");
660        assert!(extracted.phases[1].dynamic);
661        assert_eq!(
662            extracted.phases[1].description.as_deref(),
663            Some("long audit")
664        );
665    }
666
667    #[test]
668    fn extract_meta_phase_missing_label_falls_back_to_unlabeled() {
669        let lua = Lua::new();
670        let meta = lua.create_table().unwrap();
671        let phases = lua.create_table().unwrap();
672        let p0 = lua.create_table().unwrap();
673        phases.set(1, p0).unwrap();
674        meta.set("phases", phases).unwrap();
675        lua.globals().set("meta", meta).unwrap();
676
677        let extracted = extract_meta(&lua).unwrap().expect("meta must be Some");
678        assert_eq!(extracted.phases.len(), 1);
679        assert_eq!(extracted.phases[0].label, "<unlabeled>");
680        assert!(!extracted.phases[0].dynamic);
681    }
682
683    #[test]
684    fn extract_meta_missing_reasoning_defaults_to_empty_string() {
685        let lua = Lua::new();
686        let meta = lua.create_table().unwrap();
687        let phases = lua.create_table().unwrap();
688        meta.set("phases", phases).unwrap();
689        lua.globals().set("meta", meta).unwrap();
690
691        let extracted = extract_meta(&lua).unwrap().expect("meta must be Some");
692        assert_eq!(extracted.reasoning, "");
693    }
694
695    // -----------------------------------------------------------------------
696    // check_span_pairing — every branch
697    // -----------------------------------------------------------------------
698    #[test]
699    fn check_span_pairing_empty_script_is_ok() {
700        assert!(check_span_pairing("").is_ok());
701    }
702
703    #[test]
704    fn check_span_pairing_no_phase_calls_is_ok() {
705        let script = "function main() report({ ok = true }) end";
706        assert!(check_span_pairing(script).is_ok());
707    }
708
709    #[test]
710    fn check_span_pairing_only_ends_is_ok() {
711        // No begins → no pairing violation.
712        let script = "phase_end(1)";
713        assert!(check_span_pairing(script).is_ok());
714    }
715
716    #[test]
717    fn check_span_pairing_more_begins_than_ends_with_zero_ends_is_error() {
718        // The function only rejects scripts where begins > 0 AND ends == 0,
719        // so two begins + zero ends triggers the error, but two begins + one
720        // end is already balanced enough to pass.
721        let script = "phase_begin(\"a\") phase_begin(\"b\")";
722        let err = check_span_pairing(script).unwrap_err();
723        assert!(err.contains("phase_begin"));
724        assert!(err.contains("phase_end"));
725    }
726
727    #[test]
728    fn check_span_pairing_single_unpaired_begin_is_error() {
729        let script = "phase_begin(\"a\")";
730        let err = check_span_pairing(script).unwrap_err();
731        assert!(err.contains("1 phase_begin()"));
732    }
733
734    #[test]
735    fn check_span_pairing_extra_ends_do_not_count_as_paired_begins() {
736        // Multiple ends with zero begins is allowed (orphan ends are not rejected).
737        let script = "phase_end(1) phase_end(2)";
738        assert!(check_span_pairing(script).is_ok());
739    }
740
741    // -----------------------------------------------------------------------
742    // validate_workflow — every branch
743    // -----------------------------------------------------------------------
744    const WELL_FORMED: &str = r#"
745        meta = { reasoning = "do thing", phases = { { label = "x" } } }
746        function main() report({ ok = true }) end
747    "#;
748
749    #[test]
750    fn validate_workflow_well_formed_is_valid() {
751        let v = validate_workflow(WELL_FORMED).unwrap();
752        assert!(v.is_valid(), "errors: {:?}", v.errors);
753        assert!(v.has_main);
754        assert!(v.has_report_call);
755        assert!(v.span_pairing_ok);
756        let meta = v.meta.expect("meta must be Some for well-formed script");
757        assert_eq!(meta.reasoning, "do thing");
758        assert_eq!(meta.phases.len(), 1);
759        assert_eq!(meta.phases[0].label, "x");
760    }
761
762    #[test]
763    fn validate_workflow_missing_meta_is_error() {
764        let script = r#"
765            function main() report({ ok = true }) end
766        "#;
767        let v = validate_workflow(script).unwrap();
768        assert!(!v.is_valid());
769        assert!(v.errors.iter().any(|e| e.contains("meta")));
770    }
771
772    #[test]
773    fn validate_workflow_missing_main_is_error() {
774        let script = r#"
775            meta = { reasoning = "r", phases = {} }
776        "#;
777        let v = validate_workflow(script).unwrap();
778        assert!(!v.is_valid());
779        assert!(v.errors.iter().any(|e| e.contains("main")));
780    }
781
782    #[test]
783    fn validate_workflow_missing_report_call_is_error() {
784        let script = r#"
785            meta = { reasoning = "r", phases = {} }
786            function main() end
787        "#;
788        let v = validate_workflow(script).unwrap();
789        assert!(!v.is_valid());
790        assert!(v.errors.iter().any(|e| e.contains("report")));
791    }
792
793    #[test]
794    fn validate_workflow_unpaired_phase_begin_is_error() {
795        let script = r#"
796            meta = { reasoning = "r", phases = {} }
797            function main()
798              phase_begin("outer")
799              report({ ok = true })
800            end
801        "#;
802        let v = validate_workflow(script).unwrap();
803        assert!(!v.is_valid());
804        assert!(!v.span_pairing_ok);
805        assert!(v.errors.iter().any(|e| e.contains("phase_begin")));
806    }
807
808    #[test]
809    fn validate_workflow_syntax_error_returns_err() {
810        let result = validate_workflow("function main(");
811        assert!(result.is_err());
812        match result.err().expect("expected Err") {
813            ScriptError::Syntax(_) => {}
814            other => panic!("expected Syntax, got {other:?}"),
815        }
816    }
817
818    #[test]
819    fn validate_workflow_meta_can_be_nil_and_phases_omitted() {
820        let script = r#"
821            function main() report({}) end
822        "#;
823        let v = validate_workflow(script).unwrap();
824        assert!(!v.is_valid());
825        // Two errors: missing meta, no phases; plus main/report covered.
826        assert!(v.errors.iter().any(|e| e.contains("meta")));
827    }
828
829    // -----------------------------------------------------------------------
830    // Runtime lifecycle
831    // -----------------------------------------------------------------------
832    #[tokio::test]
833    async fn runtime_new_sets_args_and_ctx_globals() {
834        let runtime = make_runtime();
835        let args = runtime.lua.globals().get::<Table>("args").unwrap();
836        // Empty JSON object → empty Lua table.
837        assert_eq!(args.raw_len(), 0);
838        let ctx = runtime.lua.globals().get::<Table>("ctx").unwrap();
839        let run_id_str: String = ctx.get("run_id").unwrap();
840        assert!(!run_id_str.is_empty());
841    }
842
843    #[tokio::test]
844    async fn runtime_new_sets_globals_like_math_string_table() {
845        let runtime = make_runtime();
846        // Untouched Lua globals should be preserved (sandbox doesn't strip them).
847        let g = runtime.lua.globals();
848        assert!(g.get::<mlua::Function>("pairs").is_ok());
849        assert!(g.get::<Table>("math").is_ok());
850        assert!(g.get::<Table>("string").is_ok());
851        assert!(g.get::<Table>("table").is_ok());
852    }
853
854    #[tokio::test]
855    async fn runtime_new_does_not_expose_sandbox_globals() {
856        let runtime = make_runtime();
857        for forbidden in ["io", "os", "debug", "package", "require", "loadfile"] {
858            match runtime.lua.globals().get::<Value>(forbidden).unwrap() {
859                Value::Nil => {}
860                other => panic!("{forbidden} must be nil after Runtime::new, got {other:?}"),
861            }
862        }
863    }
864
865    #[tokio::test]
866    async fn runtime_set_completed_spans_empty_input_is_noop() {
867        let runtime = make_runtime();
868        // No-op for empty input: must not create the global.
869        runtime.set_completed_spans(&[]).unwrap();
870        // The global is absent (set only when non-empty), but accessing it as
871        // mlua::Value should not panic — we just confirm a non-error path.
872        let _ = runtime.lua.globals().get::<Value>("completed_spans");
873    }
874
875    #[tokio::test]
876    async fn runtime_set_completed_spans_creates_global_with_truthy_names() {
877        let runtime = make_runtime();
878        runtime
879            .set_completed_spans(&["explore".into(), "audit".into()])
880            .unwrap();
881        let t: Table = runtime
882            .lua
883            .globals()
884            .get("completed_spans")
885            .expect("completed_spans global must exist after set");
886        // Use string-key lookup instead of raw_len (which counts sequence
887        // entries, but the runtime sets string keys).
888        assert!(matches!(t.get::<bool>("explore").unwrap(), true));
889        assert!(matches!(t.get::<bool>("audit").unwrap(), true));
890        // Iterate to confirm both keys are present.
891        let mut seen = 0;
892        for _ in t.clone().pairs::<String, bool>() {
893            seen += 1;
894        }
895        assert_eq!(seen, 2);
896    }
897
898    #[tokio::test]
899    async fn runtime_execute_well_formed_script_returns_report_value() {
900        let runtime = make_runtime();
901        let script = r#"
902            meta = { reasoning = "ok", phases = { { label = "do" } } }
903            function main() report({ value = 42, kind = "unit" }) end
904        "#;
905        let report = runtime
906            .execute(script)
907            .expect("well-formed script must execute");
908        assert_eq!(report["value"], 42);
909        assert_eq!(report["kind"], "unit");
910    }
911
912    #[tokio::test]
913    async fn runtime_execute_returns_null_when_report_never_called() {
914        let runtime = make_runtime();
915        let script = r#"
916            meta = { reasoning = "ok", phases = {} }
917            function main() end
918        "#;
919        // validate_workflow would reject this, but Runtime::execute itself
920        // still runs — it should return Null when no report() is called.
921        let report = runtime
922            .execute(script)
923            .expect("script missing report() still executes; result is null");
924        assert_eq!(report, serde_json::Value::Null);
925    }
926
927    #[tokio::test]
928    async fn runtime_execute_missing_main_returns_missing_main_error() {
929        let runtime = make_runtime();
930        // Top-level-only assigns meta but never defines main().
931        let script = r#"
932            meta = { reasoning = "ok", phases = {} }
933        "#;
934        match runtime.execute(script).unwrap_err() {
935            ScriptError::MissingMain => {}
936            other => panic!("expected MissingMain, got {other:?}"),
937        }
938    }
939
940    #[tokio::test]
941    async fn runtime_execute_syntax_error_surfaces_syntax_variant() {
942        let runtime = make_runtime();
943        let script = "function main( report({}) end"; // unclosed paren
944        match runtime.execute(script).unwrap_err() {
945            ScriptError::Syntax(_) => {}
946            other => panic!("expected Syntax, got {other:?}"),
947        }
948    }
949
950    #[tokio::test]
951    async fn runtime_execute_meta_table_emits_plan_preview_event() {
952        let runtime = make_runtime();
953        // Subscribe *after* construction so we only see events emitted during execute().
954        let mut rx = runtime.events.subscribe();
955        let script = r#"
956            meta = { reasoning = "p", phases = { { label = "L" } } }
957            function main() report({ ok = true }) end
958        "#;
959        runtime.execute(script).unwrap();
960        let ev = rx.try_recv().expect("PlanPreview must be emitted");
961        match ev {
962            AgentEvent::PlanPreview {
963                reasoning, phases, ..
964            } => {
965                assert_eq!(reasoning, "p");
966                assert_eq!(phases.len(), 1);
967                assert_eq!(phases[0].label, "L");
968            }
969            other => panic!("expected PlanPreview, got {other:?}"),
970        }
971    }
972
973    // -----------------------------------------------------------------------
974    // Public-API surface compile-time checks
975    // -----------------------------------------------------------------------
976    #[tokio::test]
977    async fn public_api_surface() {
978        // Touch each public type/function once so a future rename / visibility
979        // change is caught at compile time.
980        let _: fn(&str) -> Result<WorkflowValidation, ScriptError> = validate_workflow;
981        let _: fn(&str) -> Result<(), ScriptError> = validate_script;
982        // WorkflowMeta / WorkflowValidation are plain structs — make sure the
983        // field shapes still match the call sites in the rest of the runtime.
984        let _m = WorkflowMeta {
985            reasoning: "".into(),
986            phases: vec![],
987        };
988        let _v = WorkflowValidation {
989            meta: None,
990            has_main: false,
991            has_report_call: false,
992            span_pairing_ok: true,
993            errors: vec![],
994            warnings: vec![],
995        };
996        // Public methods on Runtime stay callable.
997        let rt = make_runtime();
998        let _: Result<(), ScriptError> = rt.set_completed_spans(&["a".into()]);
999    }
1000
1001    // -----------------------------------------------------------------------
1002    // Helper-touching the SdkContext helper to ensure test_support compiles
1003    // -----------------------------------------------------------------------
1004    #[tokio::test]
1005    async fn test_support_helper_builds_sdk_context() {
1006        let _cx = make_sdk_context();
1007    }
1008}