Skip to main content

eval_magic/workspace/
teardown.rs

1//! End-of-run workspace cleanup.
2//!
3//! Reclaim a skill's ephemeral
4//! `.eval-magic/<skill>/` subtree without ever destroying results the user
5//! hasn't moved into version control.
6
7use std::fs;
8use std::path::Path;
9
10/// Marker `promote-baseline` drops into an iteration dir once that iteration's
11/// durable results (benchmark + gradings) are committed under the skill's
12/// `evals/baseline/`. Teardown treats its presence as "safe to delete".
13pub const PROMOTED_MARKER: &str = ".promoted.json";
14
15/// Provenance the `snapshot` command writes into each `snapshots/<label>/` dir,
16/// recording whether it was materialized from a git ref (reproducible) or copied
17/// from the working tree (not reproducible). Teardown only reclaims ref snapshots.
18pub const SNAPSHOT_META: &str = ".snapshot-meta.json";
19
20/// An iteration kept during cleanup because it still holds uncommitted results.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct KeptIteration {
23    pub iteration: String,
24    pub reason: String,
25}
26
27/// What [`cleanup_workspace`] removed and kept.
28#[derive(Debug, Default, Clone, PartialEq, Eq)]
29pub struct WorkspaceCleanupSummary {
30    /// Iteration dir names removed (promoted, or pure scaffolding).
31    pub removed_iterations: Vec<String>,
32    /// Iterations kept because they hold uncommitted results, with the reason.
33    pub kept_iterations: Vec<KeptIteration>,
34    /// Snapshot labels removed (reproducible from a git ref).
35    pub removed_snapshots: Vec<String>,
36    /// Snapshot labels kept (working-tree or legacy, can't be regenerated).
37    pub kept_snapshots: Vec<String>,
38    /// True when the skill's whole workspace subtree was removed.
39    pub workspace_removed: bool,
40}
41
42/// The reason string attached to a kept, unpromoted iteration.
43const UNCOMMITTED_REASON: &str = "uncommitted results — not promoted to evals/baseline/";
44
45/// End-of-run cleanup of a skill's `.eval-magic/<skill>/` subtree.
46///
47/// Per iteration: promoted (marker present) → removed; unpromoted but holding
48/// captured results → kept and reported; unpromoted scaffolding → removed. Per
49/// snapshot: ref-sourced → removed; working-tree or legacy → kept. Empty parents
50/// (`snapshots/`, the skill dir, the workspace root) are pruned, but a non-empty
51/// one — e.g. another skill's artifacts — is never touched.
52pub fn cleanup_workspace(workspace_root: &Path, skill_name: &str) -> WorkspaceCleanupSummary {
53    let mut summary = WorkspaceCleanupSummary::default();
54
55    let skill_dir = workspace_root.join(skill_name);
56    if !skill_dir.exists() {
57        return summary;
58    }
59
60    for name in sorted_entry_names(&skill_dir) {
61        if !name.starts_with("iteration-") {
62            continue;
63        }
64        let iter_dir = skill_dir.join(&name);
65        if !iter_dir.is_dir() {
66            continue;
67        }
68        if iter_dir.join(PROMOTED_MARKER).exists() {
69            let _ = fs::remove_dir_all(&iter_dir);
70            summary.removed_iterations.push(name);
71        } else if iteration_has_results(&iter_dir) {
72            summary.kept_iterations.push(KeptIteration {
73                iteration: name,
74                reason: UNCOMMITTED_REASON.to_string(),
75            });
76        } else {
77            let _ = fs::remove_dir_all(&iter_dir);
78            summary.removed_iterations.push(name);
79        }
80    }
81
82    let snapshots_dir = skill_dir.join("snapshots");
83    if snapshots_dir.exists() {
84        for name in sorted_entry_names(&snapshots_dir) {
85            let snap_dir = snapshots_dir.join(&name);
86            if !snap_dir.is_dir() {
87                continue;
88            }
89            if snapshot_source(&snap_dir).as_deref() == Some("ref") {
90                let _ = fs::remove_dir_all(&snap_dir);
91                summary.removed_snapshots.push(name);
92            } else {
93                summary.kept_snapshots.push(name);
94            }
95        }
96        prune_if_empty(&snapshots_dir);
97    }
98
99    prune_if_empty(&skill_dir);
100    summary.workspace_removed = !skill_dir.exists();
101    prune_if_empty(workspace_root);
102
103    summary
104}
105
106/// Directory entry names, sorted, so summary vectors are deterministic
107/// (`fs::read_dir` order is unspecified). Missing/unreadable dirs yield `[]`.
108fn sorted_entry_names(dir: &Path) -> Vec<String> {
109    let mut names: Vec<String> = match fs::read_dir(dir) {
110        Ok(rd) => rd
111            .filter_map(Result::ok)
112            .map(|e| e.file_name().to_string_lossy().into_owned())
113            .collect(),
114        Err(_) => Vec::new(),
115    };
116    names.sort();
117    names
118}
119
120/// Remove `dir` only if it exists and is empty.
121fn prune_if_empty(dir: &Path) {
122    if let Ok(mut entries) = fs::read_dir(dir)
123        && entries.next().is_none()
124    {
125        let _ = fs::remove_dir(dir);
126    }
127}
128
129/// An iteration carries "captured results" worth preserving if it reached the
130/// point of producing an aggregate (`benchmark.json`) or any per-run record or
131/// grading. Anything short of that is reproducible scaffolding.
132fn iteration_has_results(iter_dir: &Path) -> bool {
133    if iter_dir.join("benchmark.json").exists() {
134        return true;
135    }
136    let Ok(entries) = fs::read_dir(iter_dir) else {
137        return false;
138    };
139    for entry in entries.filter_map(Result::ok) {
140        let name = entry.file_name().to_string_lossy().into_owned();
141        if !name.starts_with("eval-") {
142            continue;
143        }
144        let eval_dir = entry.path();
145        if !eval_dir.is_dir() {
146            continue;
147        }
148        let Ok(conds) = fs::read_dir(&eval_dir) else {
149            continue;
150        };
151        for cond in conds.filter_map(Result::ok) {
152            let cond_dir = cond.path();
153            if !cond_dir.is_dir() {
154                continue;
155            }
156            if cond_dir.join("run.json").exists() || cond_dir.join("grading.json").exists() {
157                return true;
158            }
159        }
160    }
161    false
162}
163
164/// Read the `source` field from a snapshot's `.snapshot-meta.json`, or `None`
165/// when the file is absent or unparseable (legacy snapshots).
166fn snapshot_source(snap_dir: &Path) -> Option<String> {
167    let text = fs::read_to_string(snap_dir.join(SNAPSHOT_META)).ok()?;
168    let value: serde_json::Value = serde_json::from_str(&text).ok()?;
169    value
170        .get("source")
171        .and_then(|s| s.as_str())
172        .map(str::to_owned)
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use std::path::PathBuf;
179    use tempfile::TempDir;
180
181    /// Write `body` to `path`, creating parent dirs.
182    fn write(path: &Path, body: &str) {
183        fs::create_dir_all(path.parent().unwrap()).unwrap();
184        fs::write(path, body).unwrap();
185    }
186
187    #[derive(Default)]
188    struct IterOpts {
189        promoted: bool,
190        benchmark: bool,
191        run_record: bool,
192        grading: bool,
193        scaffolding_only: bool,
194    }
195
196    fn make_iteration(ws: &Path, skill: &str, iteration: &str, opts: IterOpts) -> PathBuf {
197        let dir = ws.join(skill).join(iteration);
198        fs::create_dir_all(&dir).unwrap();
199        if opts.scaffolding_only {
200            write(&dir.join("dispatch.json"), "[]\n");
201        }
202        if opts.benchmark {
203            write(
204                &dir.join("benchmark.json"),
205                "{\"delta\":{\"pass_rate\":0.5}}\n",
206            );
207        }
208        if opts.run_record {
209            write(
210                &dir.join("eval-e1/with_skill/run.json"),
211                "{\"eval_id\":\"e1\"}\n",
212            );
213        }
214        if opts.grading {
215            write(
216                &dir.join("eval-e1/with_skill/grading.json"),
217                "{\"summary\":{\"pass_rate\":1}}\n",
218            );
219        }
220        if opts.promoted {
221            write(&dir.join(PROMOTED_MARKER), "{\"commit\":\"abc1234\"}\n");
222        }
223        dir
224    }
225
226    fn make_snapshot(ws: &Path, skill: &str, label: &str, source: Option<&str>) -> PathBuf {
227        let dir = ws.join(skill).join("snapshots").join(label);
228        fs::create_dir_all(&dir).unwrap();
229        write(&dir.join("SKILL.md"), "snapshot body\n");
230        match source {
231            Some("ref") => write(
232                &dir.join(SNAPSHOT_META),
233                "{\"source\":\"ref\",\"ref\":\"HEAD~1\"}\n",
234            ),
235            Some(s) => write(
236                &dir.join(SNAPSHOT_META),
237                &format!("{{\"source\":\"{s}\"}}\n"),
238            ),
239            None => {}
240        }
241        dir
242    }
243
244    fn kept_names(s: &WorkspaceCleanupSummary) -> Vec<String> {
245        s.kept_iterations
246            .iter()
247            .map(|k| k.iteration.clone())
248            .collect()
249    }
250
251    #[test]
252    fn removes_promoted_iteration_and_prunes_workspace() {
253        let tmp = TempDir::new().unwrap();
254        let ws = tmp.path().join(".eval-magic");
255        fs::create_dir_all(&ws).unwrap();
256        let iter = make_iteration(
257            &ws,
258            "mr-review",
259            "iteration-1",
260            IterOpts {
261                promoted: true,
262                benchmark: true,
263                grading: true,
264                ..Default::default()
265            },
266        );
267
268        let summary = cleanup_workspace(&ws, "mr-review");
269
270        assert!(!iter.exists());
271        assert_eq!(summary.removed_iterations, vec!["iteration-1"]);
272        assert!(summary.workspace_removed);
273        assert!(!ws.join("mr-review").exists());
274        assert!(!ws.exists());
275    }
276
277    #[test]
278    fn keeps_unpromoted_iteration_with_benchmark_and_reports_it() {
279        let tmp = TempDir::new().unwrap();
280        let ws = tmp.path().join(".eval-magic");
281        fs::create_dir_all(&ws).unwrap();
282        let iter = make_iteration(
283            &ws,
284            "mr-review",
285            "iteration-1",
286            IterOpts {
287                benchmark: true,
288                ..Default::default()
289            },
290        );
291
292        let summary = cleanup_workspace(&ws, "mr-review");
293
294        assert!(iter.exists());
295        assert_eq!(summary.removed_iterations, Vec::<String>::new());
296        assert_eq!(kept_names(&summary), vec!["iteration-1"]);
297        assert!(ws.exists());
298    }
299
300    #[test]
301    fn keeps_unpromoted_iteration_with_only_a_run_record() {
302        let tmp = TempDir::new().unwrap();
303        let ws = tmp.path().join(".eval-magic");
304        fs::create_dir_all(&ws).unwrap();
305        let iter = make_iteration(
306            &ws,
307            "mr-review",
308            "iteration-1",
309            IterOpts {
310                run_record: true,
311                ..Default::default()
312            },
313        );
314
315        let summary = cleanup_workspace(&ws, "mr-review");
316
317        assert!(iter.exists());
318        assert_eq!(kept_names(&summary), vec!["iteration-1"]);
319    }
320
321    #[test]
322    fn removes_unpromoted_scaffolding_only_iteration() {
323        let tmp = TempDir::new().unwrap();
324        let ws = tmp.path().join(".eval-magic");
325        fs::create_dir_all(&ws).unwrap();
326        let iter = make_iteration(
327            &ws,
328            "mr-review",
329            "iteration-1",
330            IterOpts {
331                scaffolding_only: true,
332                ..Default::default()
333            },
334        );
335
336        let summary = cleanup_workspace(&ws, "mr-review");
337
338        assert!(!iter.exists());
339        assert_eq!(summary.removed_iterations, vec!["iteration-1"]);
340    }
341
342    #[test]
343    fn mixed_promoted_removed_kept_with_results_skill_dir_not_pruned() {
344        let tmp = TempDir::new().unwrap();
345        let ws = tmp.path().join(".eval-magic");
346        fs::create_dir_all(&ws).unwrap();
347        let promoted = make_iteration(
348            &ws,
349            "mr-review",
350            "iteration-1",
351            IterOpts {
352                promoted: true,
353                benchmark: true,
354                ..Default::default()
355            },
356        );
357        let kept = make_iteration(
358            &ws,
359            "mr-review",
360            "iteration-2",
361            IterOpts {
362                benchmark: true,
363                ..Default::default()
364            },
365        );
366
367        let summary = cleanup_workspace(&ws, "mr-review");
368
369        assert!(!promoted.exists());
370        assert!(kept.exists());
371        assert_eq!(summary.removed_iterations, vec!["iteration-1"]);
372        assert_eq!(kept_names(&summary), vec!["iteration-2"]);
373        assert!(!summary.workspace_removed);
374        assert!(ws.join("mr-review").exists());
375    }
376
377    #[test]
378    fn removes_ref_snapshots_keeps_working_tree_and_legacy() {
379        let tmp = TempDir::new().unwrap();
380        let ws = tmp.path().join(".eval-magic");
381        fs::create_dir_all(&ws).unwrap();
382        let ref_snap = make_snapshot(&ws, "mr-review", "old-ref", Some("ref"));
383        let wt_snap = make_snapshot(&ws, "mr-review", "wt", Some("working-tree"));
384        let legacy_snap = make_snapshot(&ws, "mr-review", "legacy", None);
385
386        let summary = cleanup_workspace(&ws, "mr-review");
387
388        assert!(!ref_snap.exists());
389        assert!(wt_snap.exists());
390        assert!(legacy_snap.exists());
391        assert_eq!(summary.removed_snapshots, vec!["old-ref"]);
392        assert_eq!(summary.kept_snapshots, vec!["legacy", "wt"]);
393    }
394
395    #[test]
396    fn never_touches_another_skill_and_leaves_workspace_root_intact() {
397        let tmp = TempDir::new().unwrap();
398        let ws = tmp.path().join(".eval-magic");
399        fs::create_dir_all(&ws).unwrap();
400        make_iteration(
401            &ws,
402            "mr-review",
403            "iteration-1",
404            IterOpts {
405                promoted: true,
406                ..Default::default()
407            },
408        );
409        let other_iter = make_iteration(
410            &ws,
411            "other-skill",
412            "iteration-1",
413            IterOpts {
414                benchmark: true,
415                ..Default::default()
416            },
417        );
418
419        cleanup_workspace(&ws, "mr-review");
420
421        assert!(!ws.join("mr-review").exists());
422        assert!(other_iter.exists());
423        assert!(ws.exists());
424    }
425
426    #[test]
427    fn empty_summary_when_skill_has_no_workspace() {
428        let tmp = TempDir::new().unwrap();
429        let ws = tmp.path().join(".eval-magic");
430        fs::create_dir_all(&ws).unwrap();
431
432        let summary = cleanup_workspace(&ws, "never-ran");
433
434        assert_eq!(summary, WorkspaceCleanupSummary::default());
435    }
436}