Skip to main content

mana_core/ops/
batch_verify.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use chrono::Utc;
6
7use crate::config::Config;
8use crate::discovery::find_unit_file;
9use crate::index::Index;
10use crate::ops::close::{CloseOpts, CloseOutcome};
11use crate::ops::verify::run_verify_command;
12use crate::unit::{Status, Unit};
13
14// ---------------------------------------------------------------------------
15// Public types
16// ---------------------------------------------------------------------------
17
18/// Aggregated result of a batch verify run.
19pub struct BatchVerifyResult {
20    /// IDs of units whose verify command passed (now Closed).
21    pub passed: Vec<String>,
22    /// Per-unit failure details for units that failed verify (returned to Open).
23    pub failed: Vec<BatchVerifyFailure>,
24    /// Number of unique verify commands that were executed.
25    pub commands_run: usize,
26}
27
28/// Details of a single unit's verify failure during batch verification.
29pub struct BatchVerifyFailure {
30    pub unit_id: String,
31    pub verify_command: String,
32    pub exit_code: Option<i32>,
33    pub output: String,
34    pub timed_out: bool,
35}
36
37// ---------------------------------------------------------------------------
38// Public API
39// ---------------------------------------------------------------------------
40
41/// Run batch verification for all units currently in AwaitingVerify status.
42///
43/// Groups units by their verify command string, runs each unique command exactly
44/// once, then applies the result to all units sharing that command:
45/// - Pass → unit is closed via the full lifecycle (force: true skips re-verify)
46/// - Fail → unit is set back to Open with claim released
47pub fn batch_verify(mana_dir: &Path) -> Result<BatchVerifyResult> {
48    let index = Index::load_or_rebuild(mana_dir)?;
49    let awaiting_ids: Vec<String> = index
50        .units
51        .iter()
52        .filter(|e| e.status == Status::AwaitingVerify)
53        .map(|e| e.id.clone())
54        .collect();
55
56    batch_verify_ids(mana_dir, &awaiting_ids)
57}
58
59/// Run batch verification for a specific set of unit IDs.
60///
61/// Only processes units that are in AwaitingVerify status. Units with other
62/// statuses or missing verify commands are silently skipped.
63pub fn batch_verify_ids(mana_dir: &Path, ids: &[String]) -> Result<BatchVerifyResult> {
64    if ids.is_empty() {
65        return Ok(BatchVerifyResult {
66            passed: vec![],
67            failed: vec![],
68            commands_run: 0,
69        });
70    }
71
72    let project_root = mana_dir
73        .parent()
74        .ok_or_else(|| anyhow::anyhow!("Cannot determine project root from mana dir"))?;
75
76    let config = Config::load_with_extends(mana_dir).ok();
77
78    // Load all units and group by verify command string.
79    // Units without a verify command or not in AwaitingVerify are skipped.
80    let mut groups: HashMap<String, Vec<Unit>> = HashMap::new();
81
82    for id in ids {
83        let unit_path = match find_unit_file(mana_dir, id) {
84            Ok(p) => p,
85            Err(_) => continue, // Unit not found — skip
86        };
87        let unit =
88            Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
89
90        if unit.status != Status::AwaitingVerify {
91            continue;
92        }
93
94        let verify_cmd = match &unit.verify {
95            Some(cmd) if !cmd.trim().is_empty() => cmd.clone(),
96            _ => continue, // No verify command — skip
97        };
98
99        groups.entry(verify_cmd).or_default().push(unit);
100    }
101
102    let commands_run = groups.len();
103    let mut passed = Vec::new();
104    let mut failed: Vec<BatchVerifyFailure> = Vec::new();
105
106    for (verify_cmd, units) in groups {
107        // Determine timeout from the first unit in the group (all share the same command,
108        // so using any unit's timeout is reasonable; the config fallback is always the same).
109        let timeout_secs =
110            units[0].effective_verify_timeout(config.as_ref().and_then(|c| c.verify_timeout));
111
112        let result = run_verify_command(&verify_cmd, project_root, timeout_secs)?;
113
114        if result.passed {
115            // Close each unit — force: true skips the inline verify since we just ran it.
116            for unit in units {
117                let outcome = crate::ops::close::close(
118                    mana_dir,
119                    &unit.id,
120                    CloseOpts {
121                        reason: Some("Batch verify passed".to_string()),
122                        force: true,
123                        defer_verify: false,
124                    },
125                )?;
126
127                match outcome {
128                    CloseOutcome::Closed(_) => {
129                        passed.push(unit.id.clone());
130                    }
131                    // Other outcomes (hook rejection, circuit breaker, etc.) — treat as passed
132                    // since the verify itself succeeded. The close lifecycle handled the rest.
133                    other => {
134                        match other {
135                            CloseOutcome::RejectedByHook { unit_id }
136                            | CloseOutcome::DeferredVerify { unit_id }
137                            | CloseOutcome::FeatureRequiresHuman { unit_id, .. }
138                            | CloseOutcome::CircuitBreakerTripped { unit_id, .. } => {
139                                passed.push(unit_id);
140                            }
141                            CloseOutcome::MergeConflict { .. } => {
142                                // MergeConflict has no unit_id — record using the original unit id.
143                                passed.push(unit.id.clone());
144                            }
145                            CloseOutcome::VerifyFrozenViolation { unit_id, .. } => {
146                                // Judge was changed — treat as needing attention.
147                                passed.push(unit_id);
148                            }
149                            CloseOutcome::VerifyFailed(_) => {
150                                // Should not happen with force: true.
151                            }
152                            CloseOutcome::Closed(_) => unreachable!(),
153                        }
154                    }
155                }
156            }
157        } else {
158            // Build combined output for failure reporting.
159            let combined_output = if result.timed_out {
160                format!("Verify timed out after {}s", timeout_secs.unwrap_or(0))
161            } else {
162                let stdout = result.stdout.trim();
163                let stderr = result.stderr.trim();
164                let sep = if !stdout.is_empty() && !stderr.is_empty() {
165                    "\n"
166                } else {
167                    ""
168                };
169                format!("{}{}{}", stdout, sep, stderr)
170            };
171
172            // Reopen each unit (set status back to Open, release claim).
173            for unit in &units {
174                reopen_awaiting_unit(mana_dir, &unit.id)?;
175                failed.push(BatchVerifyFailure {
176                    unit_id: unit.id.clone(),
177                    verify_command: verify_cmd.clone(),
178                    exit_code: result.exit_code,
179                    output: combined_output.clone(),
180                    timed_out: result.timed_out,
181                });
182            }
183        }
184    }
185
186    Ok(BatchVerifyResult {
187        passed,
188        failed,
189        commands_run,
190    })
191}
192
193// ---------------------------------------------------------------------------
194// Internal helpers
195// ---------------------------------------------------------------------------
196
197/// Set a unit back to Open status and release its claim.
198///
199/// Used when batch verify fails — returns the unit to the pool for re-dispatch.
200pub fn reopen_awaiting_unit(mana_dir: &Path, id: &str) -> Result<()> {
201    let unit_path =
202        find_unit_file(mana_dir, id).with_context(|| format!("Unit not found: {}", id))?;
203    let mut unit =
204        Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
205
206    unit.status = Status::Open;
207    unit.claimed_by = None;
208    unit.claimed_at = None;
209    unit.updated_at = Utc::now();
210
211    unit.to_file(&unit_path)
212        .with_context(|| format!("Failed to save unit: {}", id))?;
213
214    // Rebuild index to reflect the status change.
215    let index = Index::build(mana_dir)?;
216    index.save(mana_dir)?;
217
218    Ok(())
219}
220
221// ---------------------------------------------------------------------------
222// Tests
223// ---------------------------------------------------------------------------
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::config::Config;
229    use crate::discovery::{find_archived_unit, find_unit_file};
230    use crate::unit::{Status, Unit};
231    use std::fs;
232    use std::path::PathBuf;
233    use tempfile::TempDir;
234
235    fn setup() -> (TempDir, PathBuf) {
236        let dir = TempDir::new().unwrap();
237        let mana_dir = dir.path().join(".mana");
238        fs::create_dir(&mana_dir).unwrap();
239        Config {
240            project: "test".to_string(),
241            next_id: 1,
242            auto_close_parent: true,
243            run: None,
244            plan: None,
245            max_loops: 10,
246            max_concurrent: 4,
247            poll_interval: 30,
248            extends: vec![],
249            rules_file: None,
250            file_locking: false,
251            worktree: false,
252            on_close: None,
253            on_fail: None,
254            verify_timeout: None,
255            review: None,
256            user: None,
257            user_email: None,
258            auto_commit: false,
259            commit_template: None,
260            research: None,
261            run_model: None,
262            plan_model: None,
263            review_model: None,
264            research_model: None,
265            batch_verify: false,
266            memory_reserve_mb: 0,
267            notify: None,
268        }
269        .save(&mana_dir)
270        .unwrap();
271        (dir, mana_dir)
272    }
273
274    /// Write a unit in AwaitingVerify status to disk.
275    fn write_awaiting(mana_dir: &Path, id: &str, verify_cmd: &str) {
276        let mut unit = Unit::new(id, format!("Task {}", id));
277        unit.status = Status::AwaitingVerify;
278        unit.verify = Some(verify_cmd.to_string());
279        let slug = id.replace('.', "-");
280        unit.to_file(mana_dir.join(format!("{}-task-{}.md", id, slug)))
281            .unwrap();
282    }
283
284    // ------------------------------------------------------------------
285    // batch_verify_groups_by_command
286    // ------------------------------------------------------------------
287
288    /// 3 units where 2 share a verify command → only 2 unique commands run.
289    #[test]
290    fn batch_verify_groups_by_command() {
291        let (_dir, mana_dir) = setup();
292
293        // Units 1 and 2 share the same verify command; unit 3 has a different one.
294        write_awaiting(&mana_dir, "1", "true");
295        write_awaiting(&mana_dir, "2", "true");
296        write_awaiting(&mana_dir, "3", "true && true");
297
298        // Rebuild index so batch_verify can find the units.
299        let index = Index::build(&mana_dir).unwrap();
300        index.save(&mana_dir).unwrap();
301
302        let result = batch_verify(&mana_dir).unwrap();
303
304        assert_eq!(result.commands_run, 2, "Expected 2 unique commands run");
305        assert_eq!(result.passed.len(), 3);
306        assert!(result.failed.is_empty());
307    }
308
309    // ------------------------------------------------------------------
310    // batch_verify_passes_close_units
311    // ------------------------------------------------------------------
312
313    /// When verify passes, units should be Closed (archived).
314    #[test]
315    fn batch_verify_passes_close_units() {
316        let (_dir, mana_dir) = setup();
317        write_awaiting(&mana_dir, "1", "true");
318
319        let index = Index::build(&mana_dir).unwrap();
320        index.save(&mana_dir).unwrap();
321
322        let result = batch_verify(&mana_dir).unwrap();
323
324        assert_eq!(result.passed, vec!["1"]);
325        assert!(result.failed.is_empty());
326        assert_eq!(result.commands_run, 1);
327
328        // Unit should be archived (Closed).
329        // After archiving, the unit file is moved to the archive dir — use find_archived_unit.
330        let archive_path = find_archived_unit(&mana_dir, "1").expect("unit should be in archive");
331        let unit = Unit::from_file(archive_path).unwrap();
332        assert_eq!(unit.status, Status::Closed);
333        assert!(unit.is_archived);
334    }
335
336    // ------------------------------------------------------------------
337    // batch_verify_fails_reopen_units
338    // ------------------------------------------------------------------
339
340    /// When verify fails, units should be set back to Open.
341    #[test]
342    fn batch_verify_fails_reopen_units() {
343        let (_dir, mana_dir) = setup();
344        write_awaiting(&mana_dir, "1", "false");
345
346        let index = Index::build(&mana_dir).unwrap();
347        index.save(&mana_dir).unwrap();
348
349        let result = batch_verify(&mana_dir).unwrap();
350
351        assert!(result.passed.is_empty());
352        assert_eq!(result.failed.len(), 1);
353        assert_eq!(result.failed[0].unit_id, "1");
354        assert_eq!(result.failed[0].exit_code, Some(1));
355        assert!(!result.failed[0].timed_out);
356        assert_eq!(result.commands_run, 1);
357
358        // Unit should be back to Open.
359        let unit_path = find_unit_file(&mana_dir, "1").unwrap();
360        let unit = Unit::from_file(unit_path).unwrap();
361        assert_eq!(unit.status, Status::Open);
362        assert!(unit.claimed_by.is_none());
363    }
364
365    // ------------------------------------------------------------------
366    // batch_verify_empty_noop
367    // ------------------------------------------------------------------
368
369    /// No AwaitingVerify units → empty result, no commands run.
370    #[test]
371    fn batch_verify_empty_noop() {
372        let (_dir, mana_dir) = setup();
373
374        // Write a regular Open unit (not AwaitingVerify).
375        let mut unit = Unit::new("1", "Task 1");
376        unit.verify = Some("true".to_string());
377        unit.to_file(mana_dir.join("1-task-1.md")).unwrap();
378
379        let index = Index::build(&mana_dir).unwrap();
380        index.save(&mana_dir).unwrap();
381
382        let result = batch_verify(&mana_dir).unwrap();
383
384        assert!(result.passed.is_empty());
385        assert!(result.failed.is_empty());
386        assert_eq!(result.commands_run, 0);
387    }
388
389    // ------------------------------------------------------------------
390    // batch_verify_mixed_results
391    // ------------------------------------------------------------------
392
393    /// Some units pass, some fail → correct split across passed/failed.
394    #[test]
395    fn batch_verify_mixed_results() {
396        let (_dir, mana_dir) = setup();
397
398        write_awaiting(&mana_dir, "1", "true");
399        write_awaiting(&mana_dir, "2", "false");
400        write_awaiting(&mana_dir, "3", "true");
401
402        let index = Index::build(&mana_dir).unwrap();
403        index.save(&mana_dir).unwrap();
404
405        let result = batch_verify(&mana_dir).unwrap();
406
407        // "true" and "false" are the 2 unique commands.
408        assert_eq!(result.commands_run, 2);
409
410        // Units 1 and 3 share "true" → both pass.
411        let mut passed = result.passed.clone();
412        passed.sort();
413        assert_eq!(passed, vec!["1", "3"]);
414
415        // Unit 2 has "false" → fails.
416        assert_eq!(result.failed.len(), 1);
417        assert_eq!(result.failed[0].unit_id, "2");
418
419        // Verify on-disk states.
420        // Passing units are archived; failing unit stays in active dir.
421        let u1 = Unit::from_file(find_archived_unit(&mana_dir, "1").unwrap()).unwrap();
422        assert_eq!(u1.status, Status::Closed);
423
424        let u2 = Unit::from_file(find_unit_file(&mana_dir, "2").unwrap()).unwrap();
425        assert_eq!(u2.status, Status::Open);
426
427        let u3 = Unit::from_file(find_archived_unit(&mana_dir, "3").unwrap()).unwrap();
428        assert_eq!(u3.status, Status::Closed);
429    }
430}