Skip to main content

luft_service/
run.rs

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/// How a run's Lua script is sourced before execution. Resolved into the final
37/// script string by [`resolve_script`]; the three variants are mutually
38/// exclusive (enforced upstream by [`validate_source`]).
39#[derive(Clone, Copy)]
40pub enum ScriptSource<'a> {
41    /// Natural-language task description — planned into a workflow by the planner.
42    Nl(&'a str),
43    /// Path to a workflow file on disk — read verbatim.
44    Workflow(&'a Path),
45    /// An inline Lua script — passed through as-is.
46    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/// The result of script resolution: script text + extracted meta.
66#[derive(Debug, Clone)]
67pub struct ResolvedScript {
68    pub script: String,
69    pub meta: Option<luft_planner::PlanMeta>,
70}
71
72/// Resolve a script source AND extract any `meta = {...}` table.
73pub 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    // Read only the `status` field, so a partially-written checkpoint still
102    // gates correctly (a finished run is never silently treated as resumable).
103    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
125/// A fully-resolved run: everything needed to prepare execution, regardless of
126/// how it was requested.
127pub struct RunSpec {
128    pub run_id: RunId,
129    /// Human-readable on-disk directory name (e.g. `deep-research_1781980050`).
130    pub run_dir_name: String,
131    pub script: String,
132    pub task_label: String,
133    pub resuming: bool,
134    /// JSON arguments passed to the workflow (the Lua `args` global).
135    pub extra_args: serde_json::Value,
136    /// Declarative workflow metadata extracted from the script's `meta = {...}` table.
137    pub workflow_meta: Option<luft_planner::PlanMeta>,
138}
139
140/// Resolve a fresh run from a script source: plans NL / reads a workflow file /
141/// passes a script through, generates a new run id, and derives a task label.
142/// Callers may override `task_label` / `extra_args` on the returned spec.
143pub 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
166/// Assign the final run directory name once the base dir is known.
167/// Called by the CLI after `resolve_fresh` so `ensure_unique` can scan the
168/// filesystem.
169pub 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
197/// Resolve a resume of a specific run by reading its checkpoint + persisted
198/// `workflow.lua`. Errors if the run is missing or has finished.
199pub 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
233/// Find the most recent run that has a resumable checkpoint (CLI `--resume`
234/// with no explicit run id). Status is validated later by [`resolve_resume`].
235pub 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
295/// A prepared run: the Lua runtime plus the journal handle (single persistence
296/// instance for the run).
297pub struct PreparedRun {
298    pub runtime: Runtime,
299    pub journal: Arc<JournalStore>,
300}
301
302/// Build the per-run orchestration — journal, scheduler, event→journal
303/// forwarder, and the Lua runtime — without executing it.
304///
305/// `run_ctx` is supplied by the caller, which owns the event channel + cancel
306/// token: the CLI subscribes locally for headless output. Must be
307/// called from within a tokio runtime (it spawns the forwarder and captures
308/// `Handle::current()`).
309pub 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    // Journal: fresh runs init + persist the script (so they can be resumed);
319    // resume runs open the journal to replay cached agents.
320    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    // SQLite structured persistence — shared across runs. Optional; if the DB
345    // can't be opened (e.g. read-only filesystem) we keep going so journal +
346    // JSONL remain the source of truth for resume.
347    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    // Scheduler. Journaling is handled inside the runtime (cache-key aware), so
363    // no scheduler-level callback is required.
364    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    // Forward the scheduler event stream into BOTH:
385    //   1. Journal (checkpoint.json + events.jsonl) — for resume
386    //   2. SQLite (turns/agents/runs/spans/events tables) — for UI query
387    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                // Raw ACP events are a live observability stream, not durable
400                // history — skip them so events.jsonl / get_logs stay lean.
401                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    // Capture the runtime handle here (async context) for the blocking executor.
421    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    // Inject completed phase spans for resume so scripts can skip finished units.
432    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
448/// Execute the Lua runtime on a blocking thread and emit a terminal `RunDone`
449/// event. Returns the report value (or the script error).
450pub 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    // mlua is not Send-safe to drive from an async worker thread, and the SDK
458    // primitives call Handle::block_on internally — both require a blocking
459    // thread outside the async runtime.
460    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    // =========================================================================
512    // validate_source
513    // =========================================================================
514
515    #[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    // =========================================================================
572    // slug_sources (private, tested through its 3 branches)
573    // =========================================================================
574
575    #[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    // =========================================================================
625    // assign_dir_name
626    // =========================================================================
627
628    #[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        // ensure_unique may append _2, _3 etc but that's fine
711    }
712
713    // =========================================================================
714    // resolve_script
715    // =========================================================================
716
717    #[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    // =========================================================================
788    // resolve_fresh
789    // =========================================================================
790
791    #[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    // =========================================================================
863    // check_resumable — edge cases for the remaining statuses & corrupt data
864    // =========================================================================
865
866    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    // =========================================================================
1001    // resolve_resume
1002    // =========================================================================
1003
1004    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        // Intentionally do NOT write workflow.lua
1114
1115        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    // =========================================================================
1124    // latest_resumable
1125    // =========================================================================
1126
1127    #[test]
1128    fn latest_resumable_found() {
1129        let dir = tempfile::tempdir().unwrap();
1130
1131        // Create two run dirs; the last in sorted order should be returned.
1132        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        // Empty directory — no runs at all.
1146        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    // =========================================================================
1155    // prepare — fresh run
1156    // =========================================================================
1157
1158    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        // Create the run directory beforehand (prepare creates JournalStore there)
1182        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        // Fresh run: workflow.lua should have been written
1201        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        // Journal and runtime should be accessible
1211        let content = std::fs::read_to_string(run_dir.join("workflow.lua")).unwrap();
1212        assert_eq!(content, "report({ok=true})");
1213    }
1214
1215    // =========================================================================
1216    // prepare — resume run
1217    // =========================================================================
1218
1219    #[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        // Seed a journal + workflow.lua as if a prior run had been started.
1228        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        // Resume should NOT overwrite workflow.lua
1256        let content = std::fs::read_to_string(run_dir.join("workflow.lua")).unwrap();
1257        assert_eq!(content, "report({resumed=true})");
1258    }
1259
1260    // =========================================================================
1261    // execute
1262    // =========================================================================
1263
1264    #[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        // Invalid Lua syntax should produce a ScriptError
1324        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(_)) => {} // expected
1329            _ => panic!("expected Syntax error, got {:?}", result),
1330        }
1331    }
1332
1333    // =========================================================================
1334    // find_resumable_by_task
1335    // =========================================================================
1336
1337    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    // =========================================================================
1415    // format_duration_ago
1416    // =========================================================================
1417
1418    #[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}