1use anyhow::Result;
2use luft_core::contract::backend::{AgentBackend, RunContext};
3use luft_core::contract::event::{AgentEvent, RunStatus};
4use luft_core::contract::ids::{RunId, TokenUsage};
5use luft_core::journal::JournalStore;
6use luft_core::run_dir::{compose, derive_slug, ensure_unique};
7use luft_core::scheduler::{BackendRegistry, Scheduler, SchedulerConfig};
8use luft_core::state::{list_runs, CheckpointStatus, RunCheckpoint};
9use luft_runtime::{ExecLimits, Runtime, ScriptError};
10use luft_storage::{open_db, EventWriter};
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14pub struct RunInput {
15 pub nl: Option<String>,
16 pub workflow: Option<PathBuf>,
17 pub script: Option<String>,
18}
19
20pub enum ValidateSourceError {
21 NoneProvided,
22 MultipleProvided,
23}
24
25pub fn validate_source(input: &RunInput) -> std::result::Result<(), ValidateSourceError> {
26 let count = input.nl.is_some() as usize
27 + input.workflow.is_some() as usize
28 + input.script.is_some() as usize;
29 match count {
30 0 => Err(ValidateSourceError::NoneProvided),
31 1 => Ok(()),
32 _ => Err(ValidateSourceError::MultipleProvided),
33 }
34}
35
36#[derive(Clone, Copy)]
40pub enum ScriptSource<'a> {
41 Nl(&'a str),
43 Workflow(&'a Path),
45 Script(&'a str),
47}
48
49pub async fn resolve_script(
50 source: ScriptSource<'_>,
51 backend: Arc<dyn luft_core::contract::backend::AgentBackend>,
52 planner_cfg: luft_planner::PlannerConfig,
53) -> Result<String> {
54 match source {
55 ScriptSource::Nl(nl) => {
56 let planned = luft_planner::plan_workflow(nl, backend, &planner_cfg).await?;
57 Ok(planned.script)
58 }
59 ScriptSource::Workflow(path) => std::fs::read_to_string(path)
60 .map_err(|e| anyhow::anyhow!("failed to read workflow file: {}", e)),
61 ScriptSource::Script(s) => Ok(s.to_string()),
62 }
63}
64
65#[derive(Debug, Clone)]
67pub struct ResolvedScript {
68 pub script: String,
69 pub meta: Option<luft_planner::PlanMeta>,
70}
71
72pub async fn resolve_script_with_meta(
74 source: ScriptSource<'_>,
75 backend: Arc<dyn AgentBackend>,
76 planner_cfg: luft_planner::PlannerConfig,
77) -> Result<ResolvedScript> {
78 let script = resolve_script(source, backend, planner_cfg).await?;
79 let meta = match luft_planner::extract_meta(&script) {
80 Ok(m) => m,
81 Err(e) => {
82 tracing::warn!(error = %e, "meta extraction failed; continuing without meta");
83 None
84 }
85 };
86 Ok(ResolvedScript { script, meta })
87}
88
89pub enum ResumeCheck {
90 CanResume,
91 NotFound,
92 NotResumable(CheckpointStatus),
93}
94
95pub fn check_resumable(run_dir_name: &str, base_dir: &Path) -> ResumeCheck {
96 let run_dir = base_dir.join(run_dir_name);
97 if !run_dir.exists() {
98 return ResumeCheck::NotFound;
99 }
100
101 let checkpoint_path = run_dir.join("checkpoint.json");
104 if let Ok(content) = std::fs::read_to_string(&checkpoint_path) {
105 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) {
106 if let Some(status) = value
107 .get("status")
108 .and_then(|s| serde_json::from_value::<CheckpointStatus>(s.clone()).ok())
109 {
110 if matches!(
111 status,
112 CheckpointStatus::Completed
113 | CheckpointStatus::Cancelled
114 | CheckpointStatus::Failed
115 ) {
116 return ResumeCheck::NotResumable(status);
117 }
118 }
119 }
120 }
121
122 ResumeCheck::CanResume
123}
124
125pub struct RunSpec {
128 pub run_id: RunId,
129 pub run_dir_name: String,
131 pub script: String,
132 pub task_label: String,
133 pub resuming: bool,
134 pub extra_args: serde_json::Value,
136 pub workflow_meta: Option<luft_planner::PlanMeta>,
138}
139
140pub async fn resolve_fresh(
144 source: ScriptSource<'_>,
145 backend: Arc<dyn AgentBackend>,
146 planner_cfg: luft_planner::PlannerConfig,
147) -> Result<RunSpec> {
148 let task_label = match source {
149 ScriptSource::Nl(nl) => nl.to_string(),
150 ScriptSource::Workflow(p) => p.display().to_string(),
151 ScriptSource::Script(_) => "luft workflow".to_string(),
152 };
153 let resolved = resolve_script_with_meta(source, backend, planner_cfg).await?;
154 let run_id = RunId::now_v7();
155 Ok(RunSpec {
156 run_id,
157 script: resolved.script,
158 task_label,
159 resuming: false,
160 extra_args: serde_json::json!({}),
161 run_dir_name: String::new(),
162 workflow_meta: resolved.meta,
163 })
164}
165
166pub fn assign_dir_name(spec: &mut RunSpec, base_dir: &Path) {
170 let (wf, nl) = slug_sources(spec);
171 let slug = derive_slug(wf, nl);
172 let ts = spec
173 .run_id
174 .get_timestamp()
175 .map(|t| t.to_unix().0)
176 .unwrap_or_else(|| {
177 std::time::SystemTime::now()
178 .duration_since(std::time::UNIX_EPOCH)
179 .map(|d| d.as_secs())
180 .unwrap_or(0)
181 });
182 let dir_name = compose(&slug, ts);
183 spec.run_dir_name = ensure_unique(base_dir, &dir_name);
184}
185
186fn slug_sources(spec: &RunSpec) -> (Option<&Path>, Option<&str>) {
187 let label = &spec.task_label;
188 if label.contains('.') && !label.contains(' ') {
189 (Some(Path::new(label)), None)
190 } else if label == "luft workflow" {
191 (None, None)
192 } else {
193 (None, Some(label))
194 }
195}
196
197pub fn resolve_resume(run_dir_name: &str, base_dir: &Path) -> Result<RunSpec> {
200 let run_dir = base_dir.join(run_dir_name);
201 let content = std::fs::read_to_string(run_dir.join("checkpoint.json"))
202 .map_err(|_| anyhow::anyhow!("run {} not found", run_dir_name))?;
203 let checkpoint: RunCheckpoint = serde_json::from_str(&content)?;
204 if matches!(
205 checkpoint.status,
206 CheckpointStatus::Completed | CheckpointStatus::Cancelled | CheckpointStatus::Failed
207 ) {
208 anyhow::bail!(
209 "run {} is not resumable (status: {:?})",
210 run_dir_name,
211 checkpoint.status
212 );
213 }
214 let script = std::fs::read_to_string(run_dir.join("workflow.lua")).map_err(|_| {
215 anyhow::anyhow!(
216 "workflow.lua not found in run directory {}",
217 run_dir.display()
218 )
219 })?;
220 Ok(RunSpec {
221 run_id: checkpoint.run_id,
222 run_dir_name: run_dir_name.to_string(),
223 script,
224 task_label: checkpoint.task,
225 resuming: true,
226 extra_args: serde_json::json!({}),
227 workflow_meta: checkpoint
228 .workflow_meta
229 .and_then(|v| serde_json::from_value(v).ok()),
230 })
231}
232
233pub fn latest_resumable(base_dir: &Path) -> Result<String> {
236 let run_dirs = list_runs(base_dir)?;
237 run_dirs
238 .iter()
239 .rev()
240 .find(|dir| base_dir.join(dir).join("checkpoint.json").exists())
241 .cloned()
242 .ok_or_else(|| anyhow::anyhow!("no resumable run found"))
243}
244
245pub struct PreviousRun {
246 pub run_dir_name: String,
247 pub checkpoint: RunCheckpoint,
248}
249
250pub fn find_resumable_by_task(task: &str, base_dir: &Path) -> Result<Option<PreviousRun>> {
251 let run_dirs = list_runs(base_dir)?;
252 for dir in run_dirs.iter().rev() {
253 let cp_path = base_dir.join(dir).join("checkpoint.json");
254 let content = match std::fs::read_to_string(&cp_path) {
255 Ok(c) => c,
256 Err(_) => continue,
257 };
258 let cp: RunCheckpoint = match serde_json::from_str(&content) {
259 Ok(c) => c,
260 Err(_) => continue,
261 };
262 if cp.task == task && matches!(cp.status, CheckpointStatus::Running) {
263 return Ok(Some(PreviousRun {
264 run_dir_name: dir.clone(),
265 checkpoint: cp,
266 }));
267 }
268 }
269 Ok(None)
270}
271
272pub fn format_duration_ago(ts: u64) -> String {
273 let now = SystemTime::now()
274 .duration_since(UNIX_EPOCH)
275 .map(|d| d.as_secs())
276 .unwrap_or(0);
277 let secs = now.saturating_sub(ts);
278 if secs < 60 {
279 return "just now".to_string();
280 }
281 let mins = secs / 60;
282 if mins < 60 {
283 return format!("{}m ago", mins);
284 }
285 let hrs = mins / 60;
286 if hrs < 24 {
287 return format!("{}h ago", hrs);
288 }
289 let days = hrs / 24;
290 format!("{}d ago", days)
291}
292
293use std::time::{SystemTime, UNIX_EPOCH};
294
295pub struct PreparedRun {
298 pub runtime: Runtime,
299 pub journal: Arc<JournalStore>,
300}
301
302pub async fn prepare(
310 spec: &RunSpec,
311 backend: Arc<dyn AgentBackend>,
312 base_dir: &Path,
313 run_ctx: &RunContext,
314 max_concurrency: Option<usize>,
315) -> Result<PreparedRun> {
316 let run_dir = base_dir.join(&spec.run_dir_name);
317
318 let journal = Arc::new(
321 JournalStore::new(&run_dir)
322 .map_err(|e| anyhow::anyhow!("failed to open journal: {}", e))?,
323 );
324 if spec.resuming {
325 journal
326 .open(spec.run_id)
327 .map_err(|e| anyhow::anyhow!("failed to open journal for resume: {}", e))?;
328 } else {
329 std::fs::write(run_dir.join("workflow.lua"), &spec.script)?;
330 match &spec.workflow_meta {
331 Some(meta) => journal
332 .init_run_with_meta(
333 spec.run_id,
334 &spec.task_label,
335 serde_json::to_value(meta).unwrap(),
336 )
337 .map_err(|e| anyhow::anyhow!("failed to init journal with meta: {}", e))?,
338 None => journal
339 .init_run(spec.run_id, &spec.task_label)
340 .map_err(|e| anyhow::anyhow!("failed to init journal: {}", e))?,
341 }
342 }
343
344 let sqlite_writer = match open_db(
348 &run_dir
349 .parent()
350 .unwrap_or(base_dir)
351 .join(luft_storage::DEFAULT_DB_PATH),
352 )
353 .await
354 {
355 Ok(pool) => Some(EventWriter::new(pool)),
356 Err(e) => {
357 tracing::warn!(error = %e, "sqlite persistence disabled for this run");
358 None
359 }
360 };
361
362 let registry = BackendRegistry::new().with(backend);
365 let default_cfg = SchedulerConfig::default();
366 let actual_concurrency = max_concurrency.unwrap_or(default_cfg.max_concurrency);
367 if actual_concurrency != default_cfg.max_concurrency {
368 tracing::info!(
369 concurrency = actual_concurrency,
370 default = default_cfg.max_concurrency,
371 "using custom concurrency limit"
372 );
373 }
374 let scheduler = Scheduler::new(
375 SchedulerConfig {
376 max_concurrency: actual_concurrency,
377 ..default_cfg
378 },
379 registry,
380 None,
381 );
382 scheduler.init_run_with(spec.run_id, run_ctx.events.clone());
383
384 let store = journal.store();
388 let mut rx = run_ctx.events.subscribe();
389 let _ = run_ctx.events.send(AgentEvent::RunStarted {
390 run_id: spec.run_id,
391 task: spec.task_label.clone(),
392 ts: chrono::Utc::now(),
393 });
394 let fwd_run_id = spec.run_id;
395 let sqlite_writer_fwd = sqlite_writer.clone();
396 tokio::spawn(async move {
397 loop {
398 match rx.recv().await {
399 Ok(AgentEvent::AcpRaw { .. }) => {}
402 Ok(evt) => {
403 if let Err(e) = store.append_event(&evt) {
404 tracing::warn!(run_id = %fwd_run_id, error = %e, "failed to persist event to journal");
405 }
406 if let Some(w) = &sqlite_writer_fwd {
407 if let Err(e) = w.write_event(&evt).await {
408 tracing::warn!(run_id = %fwd_run_id, error = %e, "failed to persist event to sqlite");
409 }
410 }
411 }
412 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
413 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
414 tracing::warn!(run_id = %fwd_run_id, skipped = n, "journal forwarder lagged; dropped events");
415 }
416 }
417 }
418 });
419
420 let handle = tokio::runtime::Handle::current();
422 let runtime = Runtime::new(
423 scheduler,
424 run_ctx.clone(),
425 spec.extra_args.clone(),
426 ExecLimits::default(),
427 Some(journal.clone()),
428 handle,
429 )?;
430
431 if spec.resuming {
433 let cp_path = run_dir.join("checkpoint.json");
434 if let Ok(content) = std::fs::read_to_string(&cp_path) {
435 if let Ok(cp) = serde_json::from_str::<RunCheckpoint>(&content) {
436 let names: Vec<String> =
437 cp.completed_spans.iter().map(|s| s.name.clone()).collect();
438 if !names.is_empty() {
439 runtime.set_completed_spans(&names)?;
440 }
441 }
442 }
443 }
444
445 Ok(PreparedRun { runtime, journal })
446}
447
448pub async fn execute(
451 run_ctx: &RunContext,
452 runtime: Runtime,
453 script: String,
454) -> Result<std::result::Result<serde_json::Value, ScriptError>> {
455 let run_id = run_ctx.run_id;
456 tracing::debug!(%run_id, "execute: spawning Lua script on blocking thread");
457 let result = match tokio::task::spawn_blocking(move || runtime.execute(&script)).await {
461 Ok(r) => {
462 tracing::debug!(%run_id, "execute: blocking thread returned");
463 r
464 }
465 Err(e) => {
466 tracing::error!(%run_id, error = %e, "execution task panicked");
467 let _ = run_ctx.events.send(AgentEvent::RunDone {
468 run_id,
469 status: RunStatus::Failed,
470 total_tokens: TokenUsage::default(),
471 report: serde_json::Value::Null,
472 ts: chrono::Utc::now(),
473 });
474 return Err(anyhow::anyhow!("execution task panicked: {}", e));
475 }
476 };
477
478 let status = if result.is_ok() {
479 RunStatus::Completed
480 } else {
481 RunStatus::Failed
482 };
483 if let Err(ref e) = result {
484 tracing::warn!(%run_id, error = %e, "run finished with a script error");
485 }
486 let report = result
487 .as_ref()
488 .ok()
489 .cloned()
490 .unwrap_or(serde_json::Value::Null);
491 let _ = run_ctx.events.send(AgentEvent::RunDone {
492 run_id,
493 status,
494 total_tokens: TokenUsage::default(),
495 report,
496 ts: chrono::Utc::now(),
497 });
498 Ok(result)
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use luft_core::scheduler::{BackendRegistry, Scheduler, SchedulerConfig};
505 use luft_core::{MockBackend, MockBehavior};
506 use luft_runtime::{ExecLimits, Runtime};
507 use std::collections::HashMap;
508 use std::time::Duration;
509 use tokio_util::sync::CancellationToken;
510
511 #[test]
516 fn validate_source_none() {
517 let input = RunInput {
518 nl: None,
519 workflow: None,
520 script: None,
521 };
522 assert!(matches!(
523 validate_source(&input),
524 Err(ValidateSourceError::NoneProvided)
525 ));
526 }
527
528 #[test]
529 fn validate_source_multiple() {
530 let input = RunInput {
531 nl: Some("hi".into()),
532 workflow: Some(PathBuf::from("wf.lua")),
533 script: None,
534 };
535 assert!(matches!(
536 validate_source(&input),
537 Err(ValidateSourceError::MultipleProvided)
538 ));
539 }
540
541 #[test]
542 fn validate_source_nl_only() {
543 let input = RunInput {
544 nl: Some("hi".into()),
545 workflow: None,
546 script: None,
547 };
548 assert!(validate_source(&input).is_ok());
549 }
550
551 #[test]
552 fn validate_source_workflow_only() {
553 let input = RunInput {
554 nl: None,
555 workflow: Some(PathBuf::from("wf.lua")),
556 script: None,
557 };
558 assert!(validate_source(&input).is_ok());
559 }
560
561 #[test]
562 fn validate_source_script_only() {
563 let input = RunInput {
564 nl: None,
565 workflow: None,
566 script: Some("print(1)".into()),
567 };
568 assert!(validate_source(&input).is_ok());
569 }
570
571 #[test]
576 fn slug_sources_workflow_path() {
577 let spec = RunSpec {
578 run_id: RunId::now_v7(),
579 run_dir_name: String::new(),
580 script: String::new(),
581 task_label: "scripts/clean.lua".to_string(),
582 resuming: false,
583 extra_args: serde_json::json!({}),
584 workflow_meta: None,
585 };
586 let (wf, nl) = slug_sources(&spec);
587 assert!(wf.is_some(), "expected workflow path");
588 assert_eq!(wf.unwrap(), Path::new("scripts/clean.lua"));
589 assert!(nl.is_none());
590 }
591
592 #[test]
593 fn slug_sources_luft_workflow() {
594 let spec = RunSpec {
595 run_id: RunId::now_v7(),
596 run_dir_name: String::new(),
597 script: String::new(),
598 task_label: "luft workflow".to_string(),
599 resuming: false,
600 extra_args: serde_json::json!({}),
601 workflow_meta: None,
602 };
603 let (wf, nl) = slug_sources(&spec);
604 assert!(wf.is_none());
605 assert!(nl.is_none());
606 }
607
608 #[test]
609 fn slug_sources_nl_label() {
610 let spec = RunSpec {
611 run_id: RunId::now_v7(),
612 run_dir_name: String::new(),
613 script: String::new(),
614 task_label: "research AI trends".to_string(),
615 resuming: false,
616 extra_args: serde_json::json!({}),
617 workflow_meta: None,
618 };
619 let (wf, nl) = slug_sources(&spec);
620 assert!(wf.is_none());
621 assert_eq!(nl, Some("research AI trends"));
622 }
623
624 #[test]
629 fn assign_dir_name_nl_label() {
630 let dir = tempfile::tempdir().unwrap();
631 let mut spec = RunSpec {
632 run_id: RunId::now_v7(),
633 run_dir_name: String::new(),
634 script: String::new(),
635 task_label: "my test task".to_string(),
636 resuming: false,
637 extra_args: serde_json::json!({}),
638 workflow_meta: None,
639 };
640 assign_dir_name(&mut spec, dir.path());
641 assert!(!spec.run_dir_name.is_empty());
642 assert!(
643 spec.run_dir_name.starts_with("my-test-task_"),
644 "expected slug prefix 'my-test-task_', got '{}'",
645 spec.run_dir_name
646 );
647 }
648
649 #[test]
650 fn assign_dir_name_workflow_label() {
651 let dir = tempfile::tempdir().unwrap();
652 let mut spec = RunSpec {
653 run_id: RunId::now_v7(),
654 run_dir_name: String::new(),
655 script: String::new(),
656 task_label: "scripts/deep-research.lua".to_string(),
657 resuming: false,
658 extra_args: serde_json::json!({}),
659 workflow_meta: None,
660 };
661 assign_dir_name(&mut spec, dir.path());
662 assert!(!spec.run_dir_name.is_empty());
663 assert!(
664 spec.run_dir_name.starts_with("deep-research_"),
665 "expected slug prefix 'deep-research_', got '{}'",
666 spec.run_dir_name
667 );
668 }
669
670 #[test]
671 fn assign_dir_name_luft_label() {
672 let dir = tempfile::tempdir().unwrap();
673 let mut spec = RunSpec {
674 run_id: RunId::now_v7(),
675 run_dir_name: String::new(),
676 script: String::new(),
677 task_label: "luft workflow".to_string(),
678 resuming: false,
679 extra_args: serde_json::json!({}),
680 workflow_meta: None,
681 };
682 assign_dir_name(&mut spec, dir.path());
683 assert!(!spec.run_dir_name.is_empty());
684 assert!(
685 spec.run_dir_name.starts_with("luft-workflow_"),
686 "expected slug prefix 'luft-workflow_', got '{}'",
687 spec.run_dir_name
688 );
689 }
690
691 #[test]
692 fn assign_dir_name_v4_uuid_fallback_timestamp() {
693 let dir = tempfile::tempdir().unwrap();
694 let mut spec = RunSpec {
695 run_id: uuid::Uuid::new_v4(),
696 run_dir_name: String::new(),
697 script: String::new(),
698 task_label: "test".to_string(),
699 resuming: false,
700 extra_args: serde_json::json!({}),
701 workflow_meta: None,
702 };
703 assign_dir_name(&mut spec, dir.path());
704 assert!(!spec.run_dir_name.is_empty());
705 assert!(
706 spec.run_dir_name.starts_with("test_"),
707 "expected slug prefix 'test_', got '{}'",
708 spec.run_dir_name
709 );
710 }
712
713 #[tokio::test]
718 async fn resolve_script_script_variant() {
719 let backend: Arc<dyn AgentBackend> = Arc::new(MockBackend::new(
720 "mock",
721 vec![MockBehavior::Success {
722 output: serde_json::json!(""),
723 tokens: TokenUsage::default(),
724 delay: Duration::ZERO,
725 }],
726 ));
727 let result = resolve_script(
728 ScriptSource::Script("print(1)"),
729 backend,
730 luft_planner::PlannerConfig::default(),
731 )
732 .await
733 .unwrap();
734 assert_eq!(result, "print(1)");
735 }
736
737 #[tokio::test]
738 async fn resolve_script_workflow_variant() {
739 let dir = tempfile::tempdir().unwrap();
740 let path = dir.path().join("test.lua");
741 std::fs::write(&path, "print('hello')").unwrap();
742
743 let backend: Arc<dyn AgentBackend> = Arc::new(MockBackend::new(
744 "mock",
745 vec![MockBehavior::Success {
746 output: serde_json::json!(""),
747 tokens: TokenUsage::default(),
748 delay: Duration::ZERO,
749 }],
750 ));
751 let result = resolve_script(
752 ScriptSource::Workflow(&path),
753 backend,
754 luft_planner::PlannerConfig::default(),
755 )
756 .await
757 .unwrap();
758 assert_eq!(result, "print('hello')");
759 }
760
761 #[tokio::test]
762 async fn resolve_script_nl_variant() {
763 let output =
764 serde_json::json!("```lua\nmeta = { reasoning = \"test\", phases = {} }\nfunction main()\nagent({prompt='test'})\nreport({ok=true})\nend\n```");
765 let backend: Arc<dyn AgentBackend> = Arc::new(MockBackend::new(
766 "mock",
767 vec![MockBehavior::Success {
768 output,
769 tokens: TokenUsage::default(),
770 delay: Duration::ZERO,
771 }],
772 ));
773 let result = resolve_script(
774 ScriptSource::Nl("do something"),
775 backend,
776 luft_planner::PlannerConfig::default(),
777 )
778 .await
779 .unwrap();
780 assert!(
781 result.contains("report("),
782 "planned script must contain report()"
783 );
784 assert!(!result.contains("```"), "fences should be stripped");
785 }
786
787 #[tokio::test]
792 async fn resolve_fresh_script_variant() {
793 let backend: Arc<dyn AgentBackend> = Arc::new(MockBackend::new(
794 "mock",
795 vec![MockBehavior::Success {
796 output: serde_json::json!(""),
797 tokens: TokenUsage::default(),
798 delay: Duration::ZERO,
799 }],
800 ));
801 let spec = resolve_fresh(
802 ScriptSource::Script("print(1)"),
803 backend,
804 luft_planner::PlannerConfig::default(),
805 )
806 .await
807 .unwrap();
808 assert_eq!(spec.script, "print(1)");
809 assert_eq!(spec.task_label, "luft workflow");
810 assert!(!spec.resuming);
811 }
812
813 #[tokio::test]
814 async fn resolve_fresh_workflow_variant() {
815 let dir = tempfile::tempdir().unwrap();
816 let path = dir.path().join("my_workflow.lua");
817 std::fs::write(&path, "report({ok=true})").unwrap();
818
819 let backend: Arc<dyn AgentBackend> = Arc::new(MockBackend::new(
820 "mock",
821 vec![MockBehavior::Success {
822 output: serde_json::json!(""),
823 tokens: TokenUsage::default(),
824 delay: Duration::ZERO,
825 }],
826 ));
827 let spec = resolve_fresh(
828 ScriptSource::Workflow(&path),
829 backend,
830 luft_planner::PlannerConfig::default(),
831 )
832 .await
833 .unwrap();
834 assert_eq!(spec.script, "report({ok=true})");
835 assert!(spec.task_label.contains("my_workflow.lua"));
836 assert!(!spec.resuming);
837 }
838
839 #[tokio::test]
840 async fn resolve_fresh_nl_variant() {
841 let output =
842 serde_json::json!("```lua\nmeta = { reasoning = \"test\", phases = {} }\nfunction main()\nagent({prompt='test'})\nreport({ok=true})\nend\n```");
843 let backend: Arc<dyn AgentBackend> = Arc::new(MockBackend::new(
844 "mock",
845 vec![MockBehavior::Success {
846 output,
847 tokens: TokenUsage::default(),
848 delay: Duration::ZERO,
849 }],
850 ));
851 let spec = resolve_fresh(
852 ScriptSource::Nl("build a calculator"),
853 backend,
854 luft_planner::PlannerConfig::default(),
855 )
856 .await
857 .unwrap();
858 assert_eq!(spec.task_label, "build a calculator");
859 assert!(!spec.resuming);
860 }
861
862 fn write_checkpoint(dir: &std::path::Path, status: CheckpointStatus) {
867 let cp = RunCheckpoint {
868 run_id: RunId::now_v7(),
869 task: "t".into(),
870 status,
871 current_phase: 1,
872 completed_phases: vec![],
873 agent_results: HashMap::new(),
874 findings: vec![],
875 total_tokens: 0,
876 created_at: 0,
877 updated_at: 0,
878 completed_spans: vec![],
879 workflow_meta: None,
880 started_agent_ids: vec![],
881 };
882 std::fs::write(
883 dir.join("checkpoint.json"),
884 serde_json::to_string(&cp).unwrap(),
885 )
886 .unwrap();
887 }
888
889 #[test]
890 fn resume_check_cancelled() {
891 let dir = tempfile::tempdir().unwrap();
892 let run_dir = dir.path().join("test_123");
893 std::fs::create_dir_all(&run_dir).unwrap();
894 write_checkpoint(&run_dir, CheckpointStatus::Cancelled);
895
896 let result = check_resumable("test_123", dir.path());
897 assert!(matches!(
898 result,
899 ResumeCheck::NotResumable(CheckpointStatus::Cancelled)
900 ));
901 }
902
903 #[test]
904 fn resume_check_failed() {
905 let dir = tempfile::tempdir().unwrap();
906 let run_dir = dir.path().join("test_123");
907 std::fs::create_dir_all(&run_dir).unwrap();
908 write_checkpoint(&run_dir, CheckpointStatus::Failed);
909
910 let result = check_resumable("test_123", dir.path());
911 assert!(matches!(
912 result,
913 ResumeCheck::NotResumable(CheckpointStatus::Failed)
914 ));
915 }
916
917 #[test]
918 fn resume_check_invalid_json() {
919 let dir = tempfile::tempdir().unwrap();
920 let run_dir = dir.path().join("test_123");
921 std::fs::create_dir_all(&run_dir).unwrap();
922 std::fs::write(run_dir.join("checkpoint.json"), b"not valid json").unwrap();
923
924 let result = check_resumable("test_123", dir.path());
925 assert!(matches!(result, ResumeCheck::CanResume));
926 }
927
928 #[test]
929 fn resume_check_no_status_field() {
930 let dir = tempfile::tempdir().unwrap();
931 let run_dir = dir.path().join("test_123");
932 std::fs::create_dir_all(&run_dir).unwrap();
933 std::fs::write(
934 run_dir.join("checkpoint.json"),
935 br#"{"run_id":"00000000-0000-0000-0000-000000000000","task":"t"}"#,
936 )
937 .unwrap();
938
939 let result = check_resumable("test_123", dir.path());
940 assert!(matches!(result, ResumeCheck::CanResume));
941 }
942
943 #[test]
944 fn resume_check_wrong_status_type() {
945 let dir = tempfile::tempdir().unwrap();
946 let run_dir = dir.path().join("test_123");
947 std::fs::create_dir_all(&run_dir).unwrap();
948 std::fs::write(
949 run_dir.join("checkpoint.json"),
950 br#"{"status":123,"run_id":"00000000-0000-0000-0000-000000000000","task":"t"}"#,
951 )
952 .unwrap();
953
954 let result = check_resumable("test_123", dir.path());
955 assert!(matches!(result, ResumeCheck::CanResume));
956 }
957
958 #[test]
959 fn resume_check_not_found() {
960 let temp_dir = std::env::temp_dir().join("luft_svc_test_resume_notfound");
961 let result = check_resumable("nonexistent_123", &temp_dir);
962 assert!(matches!(result, ResumeCheck::NotFound));
963 }
964
965 #[test]
966 fn resume_check_completed() {
967 let dir = tempfile::tempdir().unwrap();
968 let run_dir = dir.path().join("test_123");
969 std::fs::create_dir_all(&run_dir).unwrap();
970 write_checkpoint(&run_dir, CheckpointStatus::Completed);
971
972 let result = check_resumable("test_123", dir.path());
973 assert!(matches!(
974 result,
975 ResumeCheck::NotResumable(CheckpointStatus::Completed)
976 ));
977 }
978
979 #[test]
980 fn resume_check_running() {
981 let dir = tempfile::tempdir().unwrap();
982 let run_dir = dir.path().join("test_123");
983 std::fs::create_dir_all(&run_dir).unwrap();
984 write_checkpoint(&run_dir, CheckpointStatus::Running);
985
986 let result = check_resumable("test_123", dir.path());
987 assert!(matches!(result, ResumeCheck::CanResume));
988 }
989
990 #[test]
991 fn resume_check_no_checkpoint() {
992 let dir = tempfile::tempdir().unwrap();
993 let run_dir = dir.path().join("test_123");
994 std::fs::create_dir_all(&run_dir).unwrap();
995
996 let result = check_resumable("test_123", dir.path());
997 assert!(matches!(result, ResumeCheck::CanResume));
998 }
999
1000 fn make_checkpoint_json(dir: &std::path::Path, run_id: RunId, status: &str, task: &str) {
1005 let cp = serde_json::json!({
1006 "run_id": run_id,
1007 "task": task,
1008 "status": status,
1009 "current_phase": 0,
1010 "completed_phases": [],
1011 "agent_results": {},
1012 "findings": [],
1013 "total_tokens": 0,
1014 "created_at": 0,
1015 "updated_at": 0,
1016 });
1017 std::fs::write(
1018 dir.join("checkpoint.json"),
1019 serde_json::to_string(&cp).unwrap(),
1020 )
1021 .unwrap();
1022 }
1023
1024 fn write_workflow_lua(dir: &std::path::Path, content: &str) {
1025 std::fs::write(dir.join("workflow.lua"), content).unwrap();
1026 }
1027
1028 #[test]
1029 fn resolve_resume_success() {
1030 let dir = tempfile::tempdir().unwrap();
1031 let run_dir = dir.path().join("my_run_123");
1032 std::fs::create_dir_all(&run_dir).unwrap();
1033 let run_id = RunId::now_v7();
1034 make_checkpoint_json(&run_dir, run_id, "running", "test task");
1035 write_workflow_lua(&run_dir, "print('resume')");
1036
1037 let spec = resolve_resume("my_run_123", dir.path()).unwrap();
1038 assert_eq!(spec.run_id, run_id);
1039 assert_eq!(spec.task_label, "test task");
1040 assert_eq!(spec.script, "print('resume')");
1041 assert!(spec.resuming);
1042 assert_eq!(spec.run_dir_name, "my_run_123");
1043 }
1044
1045 #[test]
1046 fn resolve_resume_not_found() {
1047 let dir = tempfile::tempdir().unwrap();
1048 let err = format!(
1049 "{}",
1050 resolve_resume("nonexistent", dir.path()).err().unwrap()
1051 );
1052 assert!(
1053 err.contains("not found"),
1054 "expected 'not found' error, got: {}",
1055 err
1056 );
1057 }
1058
1059 #[test]
1060 fn resolve_resume_not_resumable_completed() {
1061 let dir = tempfile::tempdir().unwrap();
1062 let run_dir = dir.path().join("my_run");
1063 std::fs::create_dir_all(&run_dir).unwrap();
1064 make_checkpoint_json(&run_dir, RunId::now_v7(), "completed", "t");
1065 write_workflow_lua(&run_dir, "x");
1066
1067 let err = format!("{}", resolve_resume("my_run", dir.path()).err().unwrap());
1068 assert!(
1069 err.contains("not resumable"),
1070 "expected 'not resumable' error, got: {}",
1071 err
1072 );
1073 }
1074
1075 #[test]
1076 fn resolve_resume_not_resumable_cancelled() {
1077 let dir = tempfile::tempdir().unwrap();
1078 let run_dir = dir.path().join("my_run");
1079 std::fs::create_dir_all(&run_dir).unwrap();
1080 make_checkpoint_json(&run_dir, RunId::now_v7(), "cancelled", "t");
1081 write_workflow_lua(&run_dir, "x");
1082
1083 let err = format!("{}", resolve_resume("my_run", dir.path()).err().unwrap());
1084 assert!(
1085 err.contains("not resumable"),
1086 "expected 'not resumable' error, got: {}",
1087 err
1088 );
1089 }
1090
1091 #[test]
1092 fn resolve_resume_not_resumable_failed() {
1093 let dir = tempfile::tempdir().unwrap();
1094 let run_dir = dir.path().join("my_run");
1095 std::fs::create_dir_all(&run_dir).unwrap();
1096 make_checkpoint_json(&run_dir, RunId::now_v7(), "failed", "t");
1097 write_workflow_lua(&run_dir, "x");
1098
1099 let err = format!("{}", resolve_resume("my_run", dir.path()).err().unwrap());
1100 assert!(
1101 err.contains("not resumable"),
1102 "expected 'not resumable' error, got: {}",
1103 err
1104 );
1105 }
1106
1107 #[test]
1108 fn resolve_resume_missing_workflow_lua() {
1109 let dir = tempfile::tempdir().unwrap();
1110 let run_dir = dir.path().join("my_run");
1111 std::fs::create_dir_all(&run_dir).unwrap();
1112 make_checkpoint_json(&run_dir, RunId::now_v7(), "running", "t");
1113 let err = format!("{}", resolve_resume("my_run", dir.path()).err().unwrap());
1116 assert!(
1117 err.contains("workflow.lua"),
1118 "expected 'workflow.lua' error, got: {}",
1119 err
1120 );
1121 }
1122
1123 #[test]
1128 fn latest_resumable_found() {
1129 let dir = tempfile::tempdir().unwrap();
1130
1131 for name in ["alpha_100", "beta_200"] {
1133 let d = dir.path().join(name);
1134 std::fs::create_dir_all(&d).unwrap();
1135 make_checkpoint_json(&d, RunId::now_v7(), "running", "t");
1136 }
1137
1138 let found = latest_resumable(dir.path()).unwrap();
1139 assert_eq!(found, "beta_200");
1140 }
1141
1142 #[test]
1143 fn latest_resumable_not_found() {
1144 let dir = tempfile::tempdir().unwrap();
1145 let err = latest_resumable(dir.path()).unwrap_err();
1147 assert!(
1148 err.to_string().contains("no resumable run"),
1149 "expected 'no resumable run' error, got: {}",
1150 err
1151 );
1152 }
1153
1154 fn make_prepare_backend() -> Arc<dyn AgentBackend> {
1159 Arc::new(MockBackend::new(
1160 "mock",
1161 vec![MockBehavior::Success {
1162 output: serde_json::json!({}),
1163 tokens: TokenUsage::default(),
1164 delay: Duration::ZERO,
1165 }],
1166 ))
1167 }
1168
1169 #[tokio::test]
1170 async fn prepare_fresh_run() {
1171 let dir = tempfile::tempdir().unwrap();
1172 let run_id = RunId::now_v7();
1173 let (tx, _rx) = tokio::sync::broadcast::channel(16);
1174 let run_ctx = RunContext {
1175 run_id,
1176 cancel: CancellationToken::new(),
1177 events: tx,
1178 };
1179 let run_dir_name = "fresh_run_42".to_string();
1180
1181 let run_dir = dir.path().join(&run_dir_name);
1183 std::fs::create_dir_all(&run_dir).unwrap();
1184
1185 let spec = RunSpec {
1186 run_id,
1187 run_dir_name,
1188 script: "report({ok=true})".to_string(),
1189 task_label: "fresh test".to_string(),
1190 resuming: false,
1191 extra_args: serde_json::json!({}),
1192 workflow_meta: None,
1193 };
1194
1195 let backend = make_prepare_backend();
1196 let _prepared = prepare(&spec, backend, dir.path(), &run_ctx, None)
1197 .await
1198 .unwrap();
1199
1200 assert!(
1202 run_dir.join("workflow.lua").exists(),
1203 "workflow.lua should exist"
1204 );
1205 assert!(
1206 run_dir.join("checkpoint.json").exists(),
1207 "checkpoint.json should exist"
1208 );
1209
1210 let content = std::fs::read_to_string(run_dir.join("workflow.lua")).unwrap();
1212 assert_eq!(content, "report({ok=true})");
1213 }
1214
1215 #[tokio::test]
1220 async fn prepare_resume_run() {
1221 let dir = tempfile::tempdir().unwrap();
1222 let run_id = RunId::now_v7();
1223 let run_dir_name = "resume_run_99".to_string();
1224 let run_dir = dir.path().join(&run_dir_name);
1225 std::fs::create_dir_all(&run_dir).unwrap();
1226
1227 let journal = JournalStore::new(&run_dir).unwrap();
1229 journal.init_run(run_id, "resume test").unwrap();
1230 write_workflow_lua(&run_dir, "report({resumed=true})");
1231 drop(journal);
1232
1233 let (tx, _rx) = tokio::sync::broadcast::channel(16);
1234 let run_ctx = RunContext {
1235 run_id,
1236 cancel: CancellationToken::new(),
1237 events: tx,
1238 };
1239
1240 let spec = RunSpec {
1241 run_id,
1242 run_dir_name,
1243 script: String::new(),
1244 task_label: String::new(),
1245 resuming: true,
1246 extra_args: serde_json::json!({}),
1247 workflow_meta: None,
1248 };
1249
1250 let backend = make_prepare_backend();
1251 let _prepared = prepare(&spec, backend, dir.path(), &run_ctx, None)
1252 .await
1253 .unwrap();
1254
1255 let content = std::fs::read_to_string(run_dir.join("workflow.lua")).unwrap();
1257 assert_eq!(content, "report({resumed=true})");
1258 }
1259
1260 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1265 async fn execute_script_success() {
1266 let run_id = RunId::now_v7();
1267 let (tx, _rx) = tokio::sync::broadcast::channel(256);
1268 let run_ctx = RunContext {
1269 run_id,
1270 cancel: CancellationToken::new(),
1271 events: tx,
1272 };
1273
1274 let backend = make_prepare_backend();
1275 let registry = BackendRegistry::new().with(backend);
1276 let scheduler = Scheduler::new(SchedulerConfig::default(), registry, None);
1277 scheduler.init_run_with(run_id, run_ctx.events.clone());
1278
1279 let handle = tokio::runtime::Handle::current();
1280 let runtime = Runtime::new(
1281 scheduler,
1282 run_ctx.clone(),
1283 serde_json::json!({}),
1284 ExecLimits::default(),
1285 None,
1286 handle,
1287 )
1288 .unwrap();
1289
1290 let script = "meta = { reasoning = \"test\", phases = {{ label = \"work\" }} }\nfunction main() report({hello = 'world'}) end".to_string();
1291 let result = execute(&run_ctx, runtime, script).await.unwrap();
1292 assert!(result.is_ok());
1293 let val = result.unwrap();
1294 assert_eq!(val["hello"], "world");
1295 }
1296
1297 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1298 async fn execute_script_error() {
1299 let run_id = RunId::now_v7();
1300 let (tx, _rx) = tokio::sync::broadcast::channel(256);
1301 let run_ctx = RunContext {
1302 run_id,
1303 cancel: CancellationToken::new(),
1304 events: tx,
1305 };
1306
1307 let backend = make_prepare_backend();
1308 let registry = BackendRegistry::new().with(backend);
1309 let scheduler = Scheduler::new(SchedulerConfig::default(), registry, None);
1310 scheduler.init_run_with(run_id, run_ctx.events.clone());
1311
1312 let handle = tokio::runtime::Handle::current();
1313 let runtime = Runtime::new(
1314 scheduler,
1315 run_ctx.clone(),
1316 serde_json::json!({}),
1317 ExecLimits::default(),
1318 None,
1319 handle,
1320 )
1321 .unwrap();
1322
1323 let script = "this is not valid lua @@".to_string();
1325 let result = execute(&run_ctx, runtime, script).await.unwrap();
1326 assert!(result.is_err());
1327 match result {
1328 Err(ScriptError::Syntax(_)) => {} _ => panic!("expected Syntax error, got {:?}", result),
1330 }
1331 }
1332
1333 fn make_checkpoint_with_task(dir: &std::path::Path, task: &str, status: &str) {
1338 let cp = serde_json::json!({
1339 "run_id": RunId::now_v7(),
1340 "task": task,
1341 "status": status,
1342 "current_phase": 0,
1343 "completed_phases": [],
1344 "agent_results": {},
1345 "findings": [],
1346 "total_tokens": 0,
1347 "created_at": 0,
1348 "updated_at": 0,
1349 });
1350 std::fs::write(
1351 dir.join("checkpoint.json"),
1352 serde_json::to_string(&cp).unwrap(),
1353 )
1354 .unwrap();
1355 }
1356
1357 #[test]
1358 fn find_resumable_by_task_match() {
1359 let dir = tempfile::tempdir().unwrap();
1360 let run_dir = dir.path().join("clean_100");
1361 std::fs::create_dir_all(&run_dir).unwrap();
1362 make_checkpoint_with_task(&run_dir, "scripts/clean.lua", "running");
1363
1364 let result = find_resumable_by_task("scripts/clean.lua", dir.path()).unwrap();
1365 assert!(result.is_some());
1366 let prev = result.unwrap();
1367 assert_eq!(prev.run_dir_name, "clean_100");
1368 assert_eq!(prev.checkpoint.task, "scripts/clean.lua");
1369 }
1370
1371 #[test]
1372 fn find_resumable_by_task_no_match() {
1373 let dir = tempfile::tempdir().unwrap();
1374 let run_dir = dir.path().join("clean_100");
1375 std::fs::create_dir_all(&run_dir).unwrap();
1376 make_checkpoint_with_task(&run_dir, "scripts/clean.lua", "running");
1377
1378 let result = find_resumable_by_task("scripts/other.lua", dir.path()).unwrap();
1379 assert!(result.is_none());
1380 }
1381
1382 #[test]
1383 fn find_resumable_by_task_skips_completed() {
1384 let dir = tempfile::tempdir().unwrap();
1385 let run_dir = dir.path().join("clean_100");
1386 std::fs::create_dir_all(&run_dir).unwrap();
1387 make_checkpoint_with_task(&run_dir, "scripts/clean.lua", "completed");
1388
1389 let result = find_resumable_by_task("scripts/clean.lua", dir.path()).unwrap();
1390 assert!(result.is_none());
1391 }
1392
1393 #[test]
1394 fn find_resumable_by_task_returns_latest() {
1395 let dir = tempfile::tempdir().unwrap();
1396 for name in ["clean_100", "clean_200"] {
1397 let run_dir = dir.path().join(name);
1398 std::fs::create_dir_all(&run_dir).unwrap();
1399 make_checkpoint_with_task(&run_dir, "scripts/clean.lua", "running");
1400 }
1401
1402 let result = find_resumable_by_task("scripts/clean.lua", dir.path()).unwrap();
1403 assert!(result.is_some());
1404 assert_eq!(result.unwrap().run_dir_name, "clean_200");
1405 }
1406
1407 #[test]
1408 fn find_resumable_by_task_empty_dir() {
1409 let dir = tempfile::tempdir().unwrap();
1410 let result = find_resumable_by_task("scripts/clean.lua", dir.path()).unwrap();
1411 assert!(result.is_none());
1412 }
1413
1414 #[test]
1419 fn format_duration_ago_just_now() {
1420 let now = std::time::SystemTime::now()
1421 .duration_since(std::time::UNIX_EPOCH)
1422 .unwrap()
1423 .as_secs();
1424 assert_eq!(format_duration_ago(now), "just now");
1425 assert_eq!(format_duration_ago(now - 30), "just now");
1426 }
1427
1428 #[test]
1429 fn format_duration_ago_minutes() {
1430 let now = std::time::SystemTime::now()
1431 .duration_since(std::time::UNIX_EPOCH)
1432 .unwrap()
1433 .as_secs();
1434 assert_eq!(format_duration_ago(now - 120), "2m ago");
1435 assert_eq!(format_duration_ago(now - 3540), "59m ago");
1436 }
1437
1438 #[test]
1439 fn format_duration_ago_hours() {
1440 let now = std::time::SystemTime::now()
1441 .duration_since(std::time::UNIX_EPOCH)
1442 .unwrap()
1443 .as_secs();
1444 assert_eq!(format_duration_ago(now - 3600), "1h ago");
1445 assert_eq!(format_duration_ago(now - 7200), "2h ago");
1446 }
1447
1448 #[test]
1449 fn format_duration_ago_days() {
1450 let now = std::time::SystemTime::now()
1451 .duration_since(std::time::UNIX_EPOCH)
1452 .unwrap()
1453 .as_secs();
1454 assert_eq!(format_duration_ago(now - 86400), "1d ago");
1455 assert_eq!(format_duration_ago(now - 172800), "2d ago");
1456 }
1457}