1use 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
46pub 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 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 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 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 self.lua.load(script).exec()?;
119
120 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 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 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
171pub 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
181fn 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 Ok(())
191}
192
193pub(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
212pub struct WorkflowMeta {
214 pub reasoning: String,
215 pub phases: Vec<PlanPhase>,
216}
217
218pub struct WorkflowValidation {
220 pub meta: Option<WorkflowMeta>,
222 pub has_main: bool,
224 pub has_report_call: bool,
226 pub span_pairing_ok: bool,
228 pub errors: Vec<String>,
230 pub warnings: Vec<String>,
232}
233
234impl WorkflowValidation {
235 pub fn is_valid(&self) -> bool {
237 self.errors.is_empty()
238 }
239}
240
241pub(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
302pub(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
317fn 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
349pub fn validate_workflow(script: &str) -> Result<WorkflowValidation, ScriptError> {
362 let lua = Lua::new();
363 apply_sandbox(&lua)?;
364 register_validation_stubs(&lua)?;
365
366 lua.load(script).exec()?;
368
369 let meta = extract_meta(&lua)?;
371
372 let has_main = lua.globals().get::<mlua::Function>("main").is_ok();
374
375 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 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 #[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 #[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 #[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 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 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 #[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 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 #[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 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 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 let script = "phase_end(1) phase_end(2)";
728 assert!(check_span_pairing(script).is_ok());
729 }
730
731 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 assert!(v.errors.iter().any(|e| e.contains("meta")));
817 }
818
819 #[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 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 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 runtime.set_completed_spans(&[]).unwrap();
860 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 assert!(matches!(t.get::<bool>("explore").unwrap(), true));
879 assert!(matches!(t.get::<bool>("audit").unwrap(), true));
880 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 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 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"; 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 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 #[tokio::test]
969 async fn public_api_surface() {
970 let _: fn(&str) -> Result<WorkflowValidation, ScriptError> = validate_workflow;
973 let _: fn(&str) -> Result<(), ScriptError> = validate_script;
974 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 let rt = make_runtime();
990 let _: Result<(), ScriptError> = rt.set_completed_spans(&["a".into()]);
991 }
992
993 #[tokio::test]
997 async fn test_support_helper_builds_sdk_context() {
998 let _cx = make_sdk_context();
999 }
1000}