Skip to main content

aurum_core/batch/
mod.rs

1//! Bounded, content-addressed, transactional multi-file batch (JOE-1726 / JOE-2220).
2//!
3//! Manifest v2 uses full source/output SHA-256, a canonical operation fingerprint,
4//! `OutputTransaction` publishes, single-writer locking, and an explicit resume
5//! decision table. Partial digests are never named `sha256` and never authorize reuse.
6
7use crate::error::{Result, UserError};
8use crate::output::{CommitMode, OutputFormat, OutputTransaction};
9use crate::provider_platform::{ProviderId, ProviderRegistry};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use std::collections::BTreeSet;
13use std::fs::{self, File, OpenOptions};
14use std::io::{Read, Write};
15use std::path::{Path, PathBuf};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18/// Manifest schema version (v2 is authoritative).
19pub const BATCH_MANIFEST_VERSION: u32 = 2;
20
21/// Legacy schema version (never silently trusted as v2).
22pub const BATCH_MANIFEST_VERSION_V1: u32 = 1;
23
24/// Default manifest filename written into the batch output directory.
25pub const BATCH_MANIFEST_NAME: &str = "aurum-batch-manifest.json";
26
27/// Single-writer lock file name (PID/run metadata only).
28pub const BATCH_LOCK_NAME: &str = "aurum-batch.lock";
29
30/// Max error message length stored in the manifest.
31pub const MAX_BATCH_ERROR_CHARS: usize = 512;
32
33/// Extensions treated as transcription inputs (lowercase, no dot).
34pub const AUDIO_EXTENSIONS: &[&str] = &[
35    "wav", "mp3", "m4a", "flac", "ogg", "oga", "opus", "webm", "aac", "mp4", "mpeg", "mpga",
36];
37
38// ---------------------------------------------------------------------------
39// Status & items
40// ---------------------------------------------------------------------------
41
42/// Per-item status in the batch manifest.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum BatchItemStatus {
46    Pending,
47    Running,
48    Succeeded,
49    Failed,
50    Skipped,
51    /// Source bytes changed since success.
52    StaleSource,
53    /// Operation fingerprint no longer matches.
54    StaleConfiguration,
55    /// Output missing, wrong size, or digest mismatch.
56    StaleOutput,
57    /// Prior process died while status was Running.
58    Interrupted,
59}
60
61impl BatchItemStatus {
62    pub fn as_str(self) -> &'static str {
63        match self {
64            Self::Pending => "pending",
65            Self::Running => "running",
66            Self::Succeeded => "succeeded",
67            Self::Failed => "failed",
68            Self::Skipped => "skipped",
69            Self::StaleSource => "stale_source",
70            Self::StaleConfiguration => "stale_configuration",
71            Self::StaleOutput => "stale_output",
72            Self::Interrupted => "interrupted",
73        }
74    }
75}
76
77/// One file in a batch (v2).
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct BatchItem {
80    /// Stable id (sha256 of normalized source path, first 16 hex chars).
81    pub id: String,
82    /// Source path as provided / discovered (UTF-8 lossy display form).
83    pub source: String,
84    /// Deterministic relative output path under `output_dir`.
85    pub output: String,
86    pub status: BatchItemStatus,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub error: Option<String>,
89    #[serde(default)]
90    pub attempts: u32,
91    /// Full SHA-256 of complete source bytes (hex). Never a partial digest.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub source_sha256: Option<String>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub source_size: Option<u64>,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub output_sha256: Option<String>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub output_size: Option<u64>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub operation_fingerprint: Option<String>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub model_digest: Option<String>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub started_at_unix: Option<u64>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub finished_at_unix: Option<u64>,
108}
109
110// ---------------------------------------------------------------------------
111// Operation fingerprint
112// ---------------------------------------------------------------------------
113
114/// Canonical structure of every option that can affect transcript output.
115///
116/// Serialized with sorted keys via serde_json::Value object insertion order
117/// stability: we hash a deterministic JSON string built from fixed field order.
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
119pub struct OperationFingerprintInput {
120    pub provider_id: String,
121    pub backend_route: String,
122    pub model_id: String,
123    /// Reviewed local model artifact digest when known (catalogue pin).
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub model_artifact_digest: Option<String>,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub support_evidence: Option<String>,
128    pub language: String,
129    pub timestamps: bool,
130    pub allow_unreliable_timestamps: bool,
131    pub output_format: String,
132    pub cleanup_style: String,
133    pub cleanup_provider: String,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub cleanup_model: Option<String>,
136    pub cleanup_segments: String,
137    /// Canonical long-form policy identity (includes env chunk override).
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub long_form_policy: Option<String>,
140    pub dto_schema_version: String,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub profile: Option<String>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub profile_evidence_version: Option<String>,
145    pub local_only: bool,
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub trust_mode: Option<String>,
148    pub aurum_behavior_version: String,
149}
150
151/// SHA-256 hex of the canonical fingerprint JSON (fixed field order).
152pub fn operation_fingerprint(input: &OperationFingerprintInput) -> String {
153    // Fixed key order — not serde_json pretty-print order dependence.
154    let payload = format!(
155        concat!(
156            "{{\n",
157            "  \"allow_unreliable_timestamps\": {},\n",
158            "  \"aurum_behavior_version\": {},\n",
159            "  \"backend_route\": {},\n",
160            "  \"cleanup_model\": {},\n",
161            "  \"cleanup_provider\": {},\n",
162            "  \"cleanup_segments\": {},\n",
163            "  \"cleanup_style\": {},\n",
164            "  \"dto_schema_version\": {},\n",
165            "  \"language\": {},\n",
166            "  \"local_only\": {},\n",
167            "  \"long_form_policy\": {},\n",
168            "  \"model_artifact_digest\": {},\n",
169            "  \"model_id\": {},\n",
170            "  \"output_format\": {},\n",
171            "  \"profile\": {},\n",
172            "  \"profile_evidence_version\": {},\n",
173            "  \"provider_id\": {},\n",
174            "  \"support_evidence\": {},\n",
175            "  \"timestamps\": {},\n",
176            "  \"trust_mode\": {}\n",
177            "}}"
178        ),
179        input.allow_unreliable_timestamps,
180        json_str(&input.aurum_behavior_version),
181        json_str(&input.backend_route),
182        json_opt_str(&input.cleanup_model),
183        json_str(&input.cleanup_provider),
184        json_str(&input.cleanup_segments),
185        json_str(&input.cleanup_style),
186        json_str(&input.dto_schema_version),
187        json_str(&input.language),
188        input.local_only,
189        json_opt_str(&input.long_form_policy),
190        json_opt_str(&input.model_artifact_digest),
191        json_str(&input.model_id),
192        json_str(&input.output_format),
193        json_opt_str(&input.profile),
194        json_opt_str(&input.profile_evidence_version),
195        json_str(&input.provider_id),
196        json_opt_str(&input.support_evidence),
197        input.timestamps,
198        json_opt_str(&input.trust_mode),
199    );
200    let mut hasher = Sha256::new();
201    hasher.update(payload.as_bytes());
202    hex::encode(hasher.finalize())
203}
204
205/// Deterministic identity string for a long-form policy (fixed field order).
206pub fn long_form_policy_fingerprint(policy: &crate::remote::LongFormPolicy) -> String {
207    format!(
208        "lfv1:target={:.6}:min={:.6}:max={:.6}:search={:.6}:silence={:.6}:rms={:.6}:overlap={:.6}:max_ov={:.6}",
209        policy.target_secs,
210        policy.min_secs,
211        policy.max_secs,
212        policy.search_secs,
213        policy.min_silence_secs,
214        policy.silence_rms_ratio,
215        policy.overlap_secs,
216        policy.max_overlap_fraction,
217    )
218}
219
220/// Reviewed catalogue pin for a local STT model id, when known.
221pub fn local_model_artifact_digest(model_id: &str) -> Option<String> {
222    let info = crate::model::lookup_model(model_id).ok()?;
223    crate::model::pinned_sha256(info.filename).map(|s| s.to_string())
224}
225
226fn json_str(s: &str) -> String {
227    serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into())
228}
229
230fn json_opt_str(s: &Option<String>) -> String {
231    match s {
232        Some(v) => json_str(v),
233        None => "null".into(),
234    }
235}
236
237// ---------------------------------------------------------------------------
238// Manifest
239// ---------------------------------------------------------------------------
240
241/// Versioned batch manifest (machine-readable resume state).
242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
243pub struct BatchManifest {
244    pub schema_version: u32,
245    pub aurum_version: String,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub commit: Option<String>,
248    /// UUID-like run id generated once per new manifest.
249    pub run_id: String,
250    pub created_at_unix: u64,
251    pub updated_at_unix: u64,
252    pub provider: String,
253    pub model: String,
254    pub language: String,
255    pub output_format: String,
256    /// Absolute or original path string for the output directory.
257    pub output_dir: String,
258    /// Canonical operation fingerprint for this run.
259    pub operation_fingerprint: String,
260    /// Optional profile used for model selection.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub profile: Option<String>,
263    pub items: Vec<BatchItem>,
264}
265
266impl BatchManifest {
267    pub fn new(
268        provider: &str,
269        model: &str,
270        language: &str,
271        format: OutputFormat,
272        output_dir: &Path,
273        profile: Option<&str>,
274        operation_fingerprint: &str,
275    ) -> Self {
276        let now = unix_now();
277        Self {
278            schema_version: BATCH_MANIFEST_VERSION,
279            aurum_version: env!("CARGO_PKG_VERSION").into(),
280            commit: std::env::var("GITHUB_SHA")
281                .or_else(|_| std::env::var("AURUM_COMMIT"))
282                .ok(),
283            run_id: new_run_id(),
284            created_at_unix: now,
285            updated_at_unix: now,
286            provider: provider.into(),
287            model: model.into(),
288            language: language.into(),
289            output_format: format.as_str().into(),
290            output_dir: output_dir.display().to_string(),
291            operation_fingerprint: operation_fingerprint.into(),
292            profile: profile.map(|s| s.to_string()),
293            items: Vec::new(),
294        }
295    }
296
297    pub fn touch(&mut self) {
298        self.updated_at_unix = unix_now();
299    }
300
301    pub fn summary(&self) -> BatchSummary {
302        let mut s = BatchSummary::default();
303        for i in &self.items {
304            s.total += 1;
305            match i.status {
306                BatchItemStatus::Pending => s.pending += 1,
307                BatchItemStatus::Running => s.running += 1,
308                BatchItemStatus::Succeeded => s.succeeded += 1,
309                BatchItemStatus::Failed => s.failed += 1,
310                BatchItemStatus::Skipped => s.skipped += 1,
311                BatchItemStatus::StaleSource => s.stale_source += 1,
312                BatchItemStatus::StaleConfiguration => s.stale_configuration += 1,
313                BatchItemStatus::StaleOutput => s.stale_output += 1,
314                BatchItemStatus::Interrupted => s.interrupted += 1,
315            }
316        }
317        s
318    }
319
320    pub fn to_json_pretty(&self) -> Result<String> {
321        serde_json::to_string_pretty(self).map_err(|e| {
322            UserError::Other {
323                message: format!("batch manifest json: {e}"),
324            }
325            .into()
326        })
327    }
328
329    /// Persist via [`OutputTransaction`] in replace mode (symlink-safe).
330    pub fn save(&self, path: &Path) -> Result<()> {
331        if let Some(parent) = path.parent() {
332            fs::create_dir_all(parent).map_err(|e| UserError::Other {
333                message: format!("create batch output dir {}: {e}", parent.display()),
334            })?;
335        }
336        reject_symlink(path)?;
337        let json = self.to_json_pretty()?;
338        OutputTransaction::new(path, CommitMode::Replace).commit_bytes(json.as_bytes())
339    }
340
341    pub fn load(path: &Path) -> Result<Self> {
342        reject_symlink(path)?;
343        let meta = fs::metadata(path).map_err(|e| UserError::Other {
344            message: format!("stat batch manifest {}: {e}", path.display()),
345        })?;
346        if !meta.is_file() {
347            return Err(UserError::Other {
348                message: format!("batch manifest {} is not a regular file", path.display()),
349            }
350            .into());
351        }
352        // Bound size (~32 MiB).
353        if meta.len() > 32 * 1024 * 1024 {
354            return Err(UserError::Other {
355                message: format!(
356                    "batch manifest {} exceeds 32 MiB size bound",
357                    path.display()
358                ),
359            }
360            .into());
361        }
362        let data = fs::read_to_string(path).map_err(|e| UserError::Other {
363            message: format!("read batch manifest {}: {e}", path.display()),
364        })?;
365        // Detect v1 before full parse into v2.
366        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
367            if let Some(ver) = v.get("schema_version").and_then(|x| x.as_u64()) {
368                if ver == BATCH_MANIFEST_VERSION_V1 as u64 {
369                    return Err(UserError::Other {
370                        message: format!(
371                            "batch manifest at {} is schema v1 and cannot be silently trusted as v2.\n  \
372                             Hint: run with a fresh --output-dir, or use --upgrade-manifest after \
373                             recomputing full source/output digests (never reuse v1 partial fingerprints).",
374                            path.display()
375                        ),
376                    }
377                    .into());
378                }
379            }
380        }
381        let m: Self = serde_json::from_str(&data).map_err(|e| UserError::Other {
382            message: format!("parse batch manifest: {e}"),
383        })?;
384        if m.schema_version != BATCH_MANIFEST_VERSION {
385            return Err(UserError::Other {
386                message: format!(
387                    "unsupported batch manifest schema_version {} (expected {BATCH_MANIFEST_VERSION})",
388                    m.schema_version
389                ),
390            }
391            .into());
392        }
393        if m.items.len() > 10_000 {
394            return Err(UserError::Other {
395                message: format!("batch manifest has {} items (max 10000)", m.items.len()),
396            }
397            .into());
398        }
399        Ok(m)
400    }
401}
402
403/// Aggregate counters for partial-success reporting.
404#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
405pub struct BatchSummary {
406    pub total: u32,
407    pub pending: u32,
408    pub running: u32,
409    pub succeeded: u32,
410    pub failed: u32,
411    pub skipped: u32,
412    #[serde(default)]
413    pub stale_source: u32,
414    #[serde(default)]
415    pub stale_configuration: u32,
416    #[serde(default)]
417    pub stale_output: u32,
418    #[serde(default)]
419    pub interrupted: u32,
420}
421
422// ---------------------------------------------------------------------------
423// Discovery / naming
424// ---------------------------------------------------------------------------
425
426/// Discover audio files under `input` (file or directory).
427pub fn discover_inputs(input: &Path, recursive: bool) -> Result<Vec<PathBuf>> {
428    if !input.exists() {
429        return Err(UserError::FileNotFound {
430            path: input.display().to_string(),
431        }
432        .into());
433    }
434    if input.is_file() {
435        if is_audio_path(input) {
436            return Ok(vec![input.to_path_buf()]);
437        }
438        return Err(UserError::InvalidAudio {
439            reason: format!("{} is not a recognised audio extension", input.display()),
440        }
441        .into());
442    }
443    if !input.is_dir() {
444        return Err(UserError::InvalidAudio {
445            reason: format!("{} is not a file or directory", input.display()),
446        }
447        .into());
448    }
449
450    let mut out = Vec::new();
451    walk_dir(input, recursive, &mut out)?;
452    out.sort();
453    if out.is_empty() {
454        return Err(UserError::Other {
455            message: format!(
456                "no audio files found under {}\n  Hint: supported extensions: {}",
457                input.display(),
458                AUDIO_EXTENSIONS.join(", ")
459            ),
460        }
461        .into());
462    }
463    const MAX_BATCH_ITEMS: usize = 10_000;
464    if out.len() > MAX_BATCH_ITEMS {
465        return Err(UserError::Other {
466            message: format!(
467                "batch has {} items (max {MAX_BATCH_ITEMS}); split the collection",
468                out.len()
469            ),
470        }
471        .into());
472    }
473    Ok(out)
474}
475
476fn walk_dir(dir: &Path, recursive: bool, out: &mut Vec<PathBuf>) -> Result<()> {
477    let entries = fs::read_dir(dir).map_err(|e| UserError::Other {
478        message: format!("read dir {}: {e}", dir.display()),
479    })?;
480    for ent in entries {
481        let ent = ent.map_err(|e| UserError::Other {
482            message: format!("read dir entry: {e}"),
483        })?;
484        let path = ent.path();
485        if path.is_dir() {
486            if recursive {
487                walk_dir(&path, true, out)?;
488            }
489        } else if is_audio_path(&path) {
490            out.push(path);
491        }
492    }
493    Ok(())
494}
495
496pub fn is_audio_path(path: &Path) -> bool {
497    path.extension()
498        .and_then(|e| e.to_str())
499        .map(|e| AUDIO_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str()))
500        .unwrap_or(false)
501}
502
503/// Deterministic output file name: `<stem>.<format>` with collision disambiguation.
504pub fn output_name_for(source: &Path, format: OutputFormat, used: &mut BTreeSet<String>) -> String {
505    let stem = source
506        .file_stem()
507        .and_then(|s| s.to_str())
508        .unwrap_or("audio");
509    let ext = format.default_extension();
510    let mut name = format!("{stem}.{ext}");
511    if used.insert(name.clone()) {
512        return name;
513    }
514    let h = short_id(&source.display().to_string());
515    name = format!("{stem}-{h}.{ext}");
516    let mut n = 2u32;
517    while !used.insert(name.clone()) {
518        name = format!("{stem}-{h}-{n}.{ext}");
519        n += 1;
520    }
521    name
522}
523
524/// Build items for a fresh batch from discovered sources.
525pub fn build_items(sources: &[PathBuf], format: OutputFormat) -> Vec<BatchItem> {
526    let mut used = BTreeSet::new();
527    sources
528        .iter()
529        .map(|src| {
530            let source = src.display().to_string();
531            let id = short_id(&source);
532            let output = output_name_for(src, format, &mut used);
533            BatchItem {
534                id,
535                source,
536                output,
537                status: BatchItemStatus::Pending,
538                error: None,
539                attempts: 0,
540                source_sha256: None,
541                source_size: None,
542                output_sha256: None,
543                output_size: None,
544                operation_fingerprint: None,
545                model_digest: None,
546                started_at_unix: None,
547                finished_at_unix: None,
548            }
549        })
550        .collect()
551}
552
553/// Merge new sources into an existing manifest for resume (keeps terminal results).
554pub fn merge_for_resume(manifest: &mut BatchManifest, sources: &[PathBuf], format: OutputFormat) {
555    let existing: BTreeSet<String> = manifest.items.iter().map(|i| i.source.clone()).collect();
556    let mut used: BTreeSet<String> = manifest.items.iter().map(|i| i.output.clone()).collect();
557    for src in sources {
558        let source = src.display().to_string();
559        if existing.contains(&source) {
560            continue;
561        }
562        let id = short_id(&source);
563        let output = output_name_for(src, format, &mut used);
564        manifest.items.push(BatchItem {
565            id,
566            source,
567            output,
568            status: BatchItemStatus::Pending,
569            error: None,
570            attempts: 0,
571            source_sha256: None,
572            source_size: None,
573            output_sha256: None,
574            output_size: None,
575            operation_fingerprint: None,
576            model_digest: None,
577            started_at_unix: None,
578            finished_at_unix: None,
579        });
580    }
581    manifest.touch();
582}
583
584// ---------------------------------------------------------------------------
585// Resume decision table
586// ---------------------------------------------------------------------------
587
588/// Outcome of verifying a previously recorded item.
589#[derive(Debug, Clone, Copy, PartialEq, Eq)]
590pub enum ResumeDecision {
591    /// Exact match — reuse as succeeded.
592    Reuse,
593    /// Needs (re)processing.
594    Work,
595    /// Configuration mismatch — fail closed unless reprocess-changed.
596    FailConfiguration,
597}
598
599/// Verify a succeeded (or terminal) item against live source/output/fingerprint.
600pub fn verify_item_for_resume(
601    item: &BatchItem,
602    output_dir: &Path,
603    current_fingerprint: &str,
604    reprocess_changed: bool,
605) -> (ResumeDecision, Option<BatchItemStatus>) {
606    match item.status {
607        BatchItemStatus::Pending => (ResumeDecision::Work, None),
608        BatchItemStatus::Failed | BatchItemStatus::Interrupted => (ResumeDecision::Work, None),
609        BatchItemStatus::Skipped => (ResumeDecision::Reuse, None),
610        BatchItemStatus::Running => {
611            // Prior process died mid-item.
612            (ResumeDecision::Work, Some(BatchItemStatus::Interrupted))
613        }
614        BatchItemStatus::StaleSource
615        | BatchItemStatus::StaleConfiguration
616        | BatchItemStatus::StaleOutput => {
617            if reprocess_changed {
618                (ResumeDecision::Work, None)
619            } else {
620                (ResumeDecision::FailConfiguration, None)
621            }
622        }
623        BatchItemStatus::Succeeded => {
624            verify_succeeded(item, output_dir, current_fingerprint, reprocess_changed)
625        }
626    }
627}
628
629fn verify_succeeded(
630    item: &BatchItem,
631    output_dir: &Path,
632    current_fingerprint: &str,
633    reprocess_changed: bool,
634) -> (ResumeDecision, Option<BatchItemStatus>) {
635    // 1. Source identity
636    let src = PathBuf::from(&item.source);
637    match sha256_file_full(&src) {
638        Ok((digest, size)) => {
639            if item.source_sha256.as_deref() != Some(digest.as_str())
640                || item.source_size != Some(size)
641            {
642                return if reprocess_changed {
643                    (ResumeDecision::Work, Some(BatchItemStatus::StaleSource))
644                } else {
645                    (
646                        ResumeDecision::FailConfiguration,
647                        Some(BatchItemStatus::StaleSource),
648                    )
649                };
650            }
651        }
652        Err(_) => {
653            return if reprocess_changed {
654                (ResumeDecision::Work, Some(BatchItemStatus::StaleSource))
655            } else {
656                (
657                    ResumeDecision::FailConfiguration,
658                    Some(BatchItemStatus::StaleSource),
659                )
660            };
661        }
662    }
663
664    // 2. Operation fingerprint
665    if item.operation_fingerprint.as_deref() != Some(current_fingerprint) {
666        return if reprocess_changed {
667            (
668                ResumeDecision::Work,
669                Some(BatchItemStatus::StaleConfiguration),
670            )
671        } else {
672            (
673                ResumeDecision::FailConfiguration,
674                Some(BatchItemStatus::StaleConfiguration),
675            )
676        };
677    }
678
679    // 3–4. Output exists, regular file, size + digest
680    let out_path = output_dir.join(&item.output);
681    if out_path
682        .symlink_metadata()
683        .map(|m| m.file_type().is_symlink())
684        .unwrap_or(false)
685    {
686        return if reprocess_changed {
687            (ResumeDecision::Work, Some(BatchItemStatus::StaleOutput))
688        } else {
689            (
690                ResumeDecision::FailConfiguration,
691                Some(BatchItemStatus::StaleOutput),
692            )
693        };
694    }
695    match sha256_file_full(&out_path) {
696        Ok((digest, size)) => {
697            if item.output_sha256.as_deref() != Some(digest.as_str())
698                || item.output_size != Some(size)
699            {
700                return if reprocess_changed {
701                    (ResumeDecision::Work, Some(BatchItemStatus::StaleOutput))
702                } else {
703                    (
704                        ResumeDecision::FailConfiguration,
705                        Some(BatchItemStatus::StaleOutput),
706                    )
707                };
708            }
709        }
710        Err(_) => {
711            return if reprocess_changed {
712                (ResumeDecision::Work, Some(BatchItemStatus::StaleOutput))
713            } else {
714                (
715                    ResumeDecision::FailConfiguration,
716                    Some(BatchItemStatus::StaleOutput),
717                )
718            };
719        }
720    }
721
722    (ResumeDecision::Reuse, None)
723}
724
725/// Apply resume verification across the manifest; returns indices to process.
726///
727/// On `FailConfiguration` without reprocess, returns an error describing the first mismatch.
728pub fn prepare_resume(
729    manifest: &mut BatchManifest,
730    current_fingerprint: &str,
731    retry_failed: bool,
732    reprocess_changed: bool,
733) -> Result<Vec<usize>> {
734    let output_dir = PathBuf::from(&manifest.output_dir);
735    let mut work = Vec::new();
736
737    // Convert abandoned Running → Interrupted first.
738    for item in &mut manifest.items {
739        if item.status == BatchItemStatus::Running {
740            item.status = BatchItemStatus::Interrupted;
741            item.error = Some(truncate_error(
742                "interrupted: prior process did not finish this item",
743            ));
744        }
745    }
746
747    for (idx, item) in manifest.items.iter_mut().enumerate() {
748        let (decision, new_status) =
749            verify_item_for_resume(item, &output_dir, current_fingerprint, reprocess_changed);
750        if let Some(st) = new_status {
751            item.status = st;
752        }
753        match decision {
754            ResumeDecision::Reuse => {}
755            ResumeDecision::Work => {
756                let should = match item.status {
757                    BatchItemStatus::Pending
758                    | BatchItemStatus::Interrupted
759                    | BatchItemStatus::StaleSource
760                    | BatchItemStatus::StaleConfiguration
761                    | BatchItemStatus::StaleOutput => true,
762                    BatchItemStatus::Failed if retry_failed || reprocess_changed => true,
763                    BatchItemStatus::Running => true,
764                    _ => false,
765                };
766                if should {
767                    // Reset to pending for reprocess paths.
768                    let resettable = matches!(
769                        item.status,
770                        BatchItemStatus::StaleSource
771                            | BatchItemStatus::StaleConfiguration
772                            | BatchItemStatus::StaleOutput
773                            | BatchItemStatus::Interrupted
774                            | BatchItemStatus::Failed
775                    );
776                    let may_reset = reprocess_changed
777                        || matches!(
778                            item.status,
779                            BatchItemStatus::Interrupted | BatchItemStatus::Failed
780                        );
781                    if resettable && may_reset {
782                        item.status = BatchItemStatus::Pending;
783                        item.error = None;
784                        item.output_sha256 = None;
785                        item.output_size = None;
786                    }
787                    work.push(idx);
788                }
789            }
790            ResumeDecision::FailConfiguration => {
791                return Err(UserError::Other {
792                    message: format!(
793                        "batch resume refused for item '{}' (status={}): source/config/output mismatch.\n  \
794                         Hint: pass --reprocess-changed to opt in to reprocessing, or use a new --output-dir",
795                        item.source,
796                        item.status.as_str()
797                    ),
798                }
799                .into());
800            }
801        }
802    }
803    // Also pick pure pending that verify returned Work for.
804    // work_indices fallback for pending not yet covered.
805    for (idx, item) in manifest.items.iter().enumerate() {
806        if item.status == BatchItemStatus::Pending && !work.contains(&idx) {
807            work.push(idx);
808        }
809        if retry_failed && item.status == BatchItemStatus::Failed && !work.contains(&idx) {
810            work.push(idx);
811        }
812    }
813    work.sort_unstable();
814    work.dedup();
815    Ok(work)
816}
817
818/// Indices that still need work (simple path without full verify).
819pub fn work_indices(manifest: &BatchManifest, retry_failed: bool) -> Vec<usize> {
820    manifest
821        .items
822        .iter()
823        .enumerate()
824        .filter(|(_, i)| match i.status {
825            BatchItemStatus::Pending | BatchItemStatus::Running | BatchItemStatus::Interrupted => {
826                true
827            }
828            BatchItemStatus::Failed if retry_failed => true,
829            BatchItemStatus::StaleSource
830            | BatchItemStatus::StaleConfiguration
831            | BatchItemStatus::StaleOutput => true,
832            _ => false,
833        })
834        .map(|(idx, _)| idx)
835        .collect()
836}
837
838// ---------------------------------------------------------------------------
839// Digests
840// ---------------------------------------------------------------------------
841
842/// Full SHA-256 of complete file bytes plus size. Authoritative for resume.
843///
844/// Fails closed when the number of bytes read does not match the size observed
845/// at open time (concurrent truncation/extension). The returned size is the
846/// number of bytes that contributed to the digest.
847pub fn sha256_file_full(path: &Path) -> Result<(String, u64)> {
848    let mut f = File::open(path).map_err(|e| UserError::Other {
849        message: format!("open {}: {e}", path.display()),
850    })?;
851    let meta = f.metadata().map_err(|e| UserError::Other {
852        message: format!("stat {}: {e}", path.display()),
853    })?;
854    if meta.file_type().is_symlink() {
855        return Err(UserError::Other {
856            message: format!("{} is a symlink (rejected)", path.display()),
857        }
858        .into());
859    }
860    let expected_len = meta.len();
861    let mut hasher = Sha256::new();
862    let mut buf = [0u8; 1024 * 64];
863    let mut total = 0u64;
864    loop {
865        let n = f.read(&mut buf).map_err(|e| UserError::Other {
866            message: format!("read {}: {e}", path.display()),
867        })?;
868        if n == 0 {
869            break;
870        }
871        hasher.update(&buf[..n]);
872        total += n as u64;
873    }
874    if total != expected_len {
875        return Err(UserError::Other {
876            message: format!(
877                "source size changed during hash ({}): metadata {expected_len} bytes, read {total} bytes",
878                path.display()
879            ),
880        }
881        .into());
882    }
883    Ok((hex::encode(hasher.finalize()), total))
884}
885
886/// Re-hash `path` and require an exact match with a previously captured identity.
887///
888/// Used after decode so a batch transcript is only accepted when the on-disk
889/// source still matches the digest recorded before `load_audio` (v0.0.23 P1a).
890pub fn verify_source_identity(
891    path: &Path,
892    expected_digest: &str,
893    expected_size: u64,
894) -> Result<()> {
895    let (digest, size) = sha256_file_full(path)?;
896    if digest != expected_digest || size != expected_size {
897        return Err(UserError::Other {
898            message: format!(
899                "source changed between identity capture and use ({}): \
900                 expected {expected_digest}/{expected_size}, got {digest}/{size}",
901                path.display()
902            ),
903        }
904        .into());
905    }
906    Ok(())
907}
908
909/// Cheap discovery preflight identity (size + first 1 MiB). **Not** named sha256;
910/// must never authorize reuse of a succeeded result.
911pub fn discovery_preflight_id(path: &Path) -> Result<String> {
912    let mut f = File::open(path).map_err(|e| UserError::Other {
913        message: format!("open {}: {e}", path.display()),
914    })?;
915    let meta = f.metadata().map_err(|e| UserError::Other {
916        message: format!("stat {}: {e}", path.display()),
917    })?;
918    let mut buf = vec![0u8; 1024 * 1024];
919    let n = f.read(&mut buf).map_err(|e| UserError::Other {
920        message: format!("read {}: {e}", path.display()),
921    })?;
922    let mut hasher = Sha256::new();
923    hasher.update(b"preflight-v1:");
924    hasher.update(meta.len().to_le_bytes());
925    hasher.update(&buf[..n]);
926    Ok(hex::encode(hasher.finalize()))
927}
928
929/// Deprecated name retained as a thin alias that documents the partial nature.
930/// Prefer [`sha256_file_full`] for resume and [`discovery_preflight_id`] for discovery.
931#[deprecated(note = "use sha256_file_full for resume; discovery_preflight_id for cheap discovery")]
932pub fn fingerprint_file(path: &Path) -> Result<String> {
933    discovery_preflight_id(path)
934}
935
936pub fn short_id(s: &str) -> String {
937    let mut hasher = Sha256::new();
938    hasher.update(s.as_bytes());
939    let full = hex::encode(hasher.finalize());
940    full[..16].to_string()
941}
942
943pub fn truncate_error(msg: &str) -> String {
944    let mut out: String = msg.chars().take(MAX_BATCH_ERROR_CHARS).collect();
945    if msg.chars().count() > MAX_BATCH_ERROR_CHARS {
946        out.push('…');
947    }
948    out
949}
950
951fn unix_now() -> u64 {
952    SystemTime::now()
953        .duration_since(UNIX_EPOCH)
954        .map(|d| d.as_secs())
955        .unwrap_or(0)
956}
957
958fn new_run_id() -> String {
959    let mut hasher = Sha256::new();
960    hasher.update(unix_now().to_le_bytes());
961    hasher.update(format!("{:?}", std::thread::current().id()).as_bytes());
962    #[cfg(unix)]
963    {
964        hasher.update(std::process::id().to_le_bytes());
965    }
966    let full = hex::encode(hasher.finalize());
967    full[..32].to_string()
968}
969
970/// Manifest path inside an output directory.
971pub fn manifest_path(output_dir: &Path) -> PathBuf {
972    output_dir.join(BATCH_MANIFEST_NAME)
973}
974
975pub fn lock_path(output_dir: &Path) -> PathBuf {
976    output_dir.join(BATCH_LOCK_NAME)
977}
978
979fn reject_symlink(path: &Path) -> Result<()> {
980    if let Ok(meta) = fs::symlink_metadata(path) {
981        if meta.file_type().is_symlink() {
982            return Err(UserError::Other {
983                message: format!("refusing symlink path {}", path.display()),
984            }
985            .into());
986        }
987    }
988    Ok(())
989}
990
991// ---------------------------------------------------------------------------
992// Single-writer lock
993// ---------------------------------------------------------------------------
994
995/// Batch directory lock (PID + run_id + start time; no private paths).
996#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
997pub struct BatchLock {
998    pub pid: u32,
999    pub run_id: String,
1000    pub started_at_unix: u64,
1001    pub aurum_version: String,
1002}
1003
1004/// Acquire exclusive lock for `output_dir`. Fails if a live lock exists.
1005pub fn acquire_batch_lock(output_dir: &Path, run_id: &str) -> Result<BatchLockGuard> {
1006    fs::create_dir_all(output_dir).map_err(|e| UserError::Other {
1007        message: format!("create batch output dir {}: {e}", output_dir.display()),
1008    })?;
1009    let path = lock_path(output_dir);
1010    if path.exists() {
1011        // Do not break a live lock automatically.
1012        let existing = fs::read_to_string(&path).unwrap_or_default();
1013        return Err(UserError::Other {
1014            message: format!(
1015                "batch lock exists at {} — another process may be writing this output directory.\n  \
1016                 Lock metadata: {}\n  \
1017                 Hint: if the holder is dead, remove the lock file deliberately after verifying the PID is gone",
1018                path.display(),
1019                existing.chars().take(200).collect::<String>()
1020            ),
1021        }
1022        .into());
1023    }
1024    let lock = BatchLock {
1025        pid: std::process::id(),
1026        run_id: run_id.into(),
1027        started_at_unix: unix_now(),
1028        aurum_version: env!("CARGO_PKG_VERSION").into(),
1029    };
1030    let json = serde_json::to_string_pretty(&lock).map_err(|e| UserError::Other {
1031        message: format!("batch lock json: {e}"),
1032    })?;
1033    // Exclusive create.
1034    let mut opts = OpenOptions::new();
1035    opts.write(true).create_new(true);
1036    let mut f = opts.open(&path).map_err(|e| UserError::Other {
1037        message: format!("create batch lock {}: {e}", path.display()),
1038    })?;
1039    f.write_all(json.as_bytes()).map_err(|e| UserError::Other {
1040        message: format!("write batch lock: {e}"),
1041    })?;
1042    f.sync_all().ok();
1043    Ok(BatchLockGuard { path, lock })
1044}
1045
1046/// RAII guard that removes the lock file on drop.
1047pub struct BatchLockGuard {
1048    path: PathBuf,
1049    lock: BatchLock,
1050}
1051
1052impl BatchLockGuard {
1053    pub fn lock(&self) -> &BatchLock {
1054        &self.lock
1055    }
1056}
1057
1058impl Drop for BatchLockGuard {
1059    fn drop(&mut self) {
1060        let _ = fs::remove_file(&self.path);
1061    }
1062}
1063
1064// ---------------------------------------------------------------------------
1065// Provider registry parity
1066// ---------------------------------------------------------------------------
1067
1068/// Validate that `provider` is a registered STT provider (not a hard-coded subset).
1069pub fn validate_batch_stt_provider(
1070    registry: &ProviderRegistry,
1071    provider: &str,
1072) -> Result<ProviderId> {
1073    let id = ProviderId::parse(provider)?;
1074    // Resolve via registry STT factory — same path as one-file CLI/library.
1075    registry.stt_factory(&id)?;
1076    Ok(id)
1077}
1078
1079// ---------------------------------------------------------------------------
1080// Tests
1081// ---------------------------------------------------------------------------
1082
1083#[cfg(test)]
1084mod tests {
1085    use super::*;
1086    use tempfile::tempdir;
1087
1088    fn fp_input(model: &str) -> OperationFingerprintInput {
1089        OperationFingerprintInput {
1090            provider_id: "local".into(),
1091            backend_route: "whisper_cpp".into(),
1092            model_id: model.into(),
1093            model_artifact_digest: local_model_artifact_digest(model),
1094            support_evidence: None,
1095            language: "en".into(),
1096            timestamps: false,
1097            allow_unreliable_timestamps: false,
1098            output_format: "txt".into(),
1099            cleanup_style: "raw".into(),
1100            cleanup_provider: "rules".into(),
1101            cleanup_model: None,
1102            cleanup_segments: "auto".into(),
1103            long_form_policy: Some(long_form_policy_fingerprint(
1104                &crate::remote::LongFormPolicy::default(),
1105            )),
1106            dto_schema_version: "1".into(),
1107            profile: None,
1108            profile_evidence_version: None,
1109            local_only: false,
1110            trust_mode: None,
1111            aurum_behavior_version: "0.0.22".into(),
1112        }
1113    }
1114
1115    #[test]
1116    fn fingerprint_stable_and_sensitive() {
1117        let a = operation_fingerprint(&fp_input("base"));
1118        let b = operation_fingerprint(&fp_input("base"));
1119        assert_eq!(a, b);
1120        let c = operation_fingerprint(&fp_input("tiny-q5_1"));
1121        assert_ne!(a, c);
1122        let mut x = fp_input("base");
1123        x.timestamps = true;
1124        assert_ne!(a, operation_fingerprint(&x));
1125        let mut y = fp_input("base");
1126        y.long_form_policy = Some("different".into());
1127        assert_ne!(a, operation_fingerprint(&y));
1128        let mut z = fp_input("base");
1129        z.model_artifact_digest = Some("deadbeef".into());
1130        assert_ne!(a, operation_fingerprint(&z));
1131    }
1132
1133    #[test]
1134    fn long_form_policy_fingerprint_stable() {
1135        let p = crate::remote::LongFormPolicy::default();
1136        let a = long_form_policy_fingerprint(&p);
1137        let b = long_form_policy_fingerprint(&p);
1138        assert_eq!(a, b);
1139        assert!(a.starts_with("lfv1:"));
1140    }
1141
1142    #[test]
1143    fn verify_source_identity_accepts_stable_and_rejects_change() {
1144        let dir = tempdir().unwrap();
1145        let path = dir.path().join("src.bin");
1146        fs::write(&path, b"stable-audio-bytes").unwrap();
1147        let (d, s) = sha256_file_full(&path).unwrap();
1148        verify_source_identity(&path, &d, s).unwrap();
1149        fs::write(&path, b"tampered-audio-bytes").unwrap();
1150        assert!(verify_source_identity(&path, &d, s).is_err());
1151    }
1152
1153    #[test]
1154    fn full_digest_detects_change_after_first_mib() {
1155        let dir = tempdir().unwrap();
1156        let path = dir.path().join("big.bin");
1157        let mut data = vec![0u8; 1024 * 1024 + 64];
1158        data[0] = 1;
1159        fs::write(&path, &data).unwrap();
1160        let (d1, s1) = sha256_file_full(&path).unwrap();
1161        // Change only after first MiB; size unchanged.
1162        data[1024 * 1024 + 10] = 0xAB;
1163        fs::write(&path, &data).unwrap();
1164        let (d2, s2) = sha256_file_full(&path).unwrap();
1165        assert_eq!(s1, s2);
1166        assert_ne!(d1, d2);
1167        // Preflight may miss the change (only first MiB) — that's why it must not authorize reuse.
1168        let p1 = discovery_preflight_id(&path).unwrap();
1169        data[1024 * 1024 + 10] = 0x00;
1170        fs::write(&path, &data).unwrap();
1171        let p2 = discovery_preflight_id(&path).unwrap();
1172        // After restore of tail, preflight of original vs changed-tail:
1173        // re-write changed again for preflight equality check on first MiB only
1174        let mut data2 = vec![0u8; 1024 * 1024 + 64];
1175        data2[0] = 1;
1176        fs::write(&path, &data2).unwrap();
1177        let p_base = discovery_preflight_id(&path).unwrap();
1178        data2[1024 * 1024 + 10] = 0xAB;
1179        fs::write(&path, &data2).unwrap();
1180        let p_changed_tail = discovery_preflight_id(&path).unwrap();
1181        assert_eq!(
1182            p_base, p_changed_tail,
1183            "preflight must only see first MiB+size"
1184        );
1185        let _ = (p1, p2);
1186    }
1187
1188    #[test]
1189    fn discover_and_names_stable() {
1190        let dir = tempdir().unwrap();
1191        fs::write(dir.path().join("a.wav"), b"x").unwrap();
1192        fs::write(dir.path().join("b.mp3"), b"y").unwrap();
1193        fs::write(dir.path().join("skip.txt"), b"z").unwrap();
1194        let found = discover_inputs(dir.path(), false).unwrap();
1195        assert_eq!(found.len(), 2);
1196        let items = build_items(&found, OutputFormat::Txt);
1197        assert_eq!(items[0].output, "a.txt");
1198        assert_eq!(items[1].output, "b.txt");
1199        assert_eq!(items[0].status, BatchItemStatus::Pending);
1200    }
1201
1202    #[test]
1203    fn resume_keeps_succeeded() {
1204        let dir = tempdir().unwrap();
1205        let fp = operation_fingerprint(&fp_input("base"));
1206        let mut m = BatchManifest::new(
1207            "local",
1208            "base",
1209            "auto",
1210            OutputFormat::Txt,
1211            dir.path(),
1212            None,
1213            &fp,
1214        );
1215        m.items.push(BatchItem {
1216            id: "1".into(),
1217            source: "/x/a.wav".into(),
1218            output: "a.txt".into(),
1219            status: BatchItemStatus::Succeeded,
1220            error: None,
1221            attempts: 1,
1222            source_sha256: None,
1223            source_size: None,
1224            output_sha256: None,
1225            output_size: None,
1226            operation_fingerprint: Some(fp.clone()),
1227            model_digest: None,
1228            started_at_unix: None,
1229            finished_at_unix: None,
1230        });
1231        m.items.push(BatchItem {
1232            id: "2".into(),
1233            source: "/x/b.wav".into(),
1234            output: "b.txt".into(),
1235            status: BatchItemStatus::Failed,
1236            error: Some("boom".into()),
1237            attempts: 1,
1238            source_sha256: None,
1239            source_size: None,
1240            output_sha256: None,
1241            output_size: None,
1242            operation_fingerprint: Some(fp),
1243            model_digest: None,
1244            started_at_unix: None,
1245            finished_at_unix: None,
1246        });
1247        let work = work_indices(&m, false);
1248        assert!(work.is_empty());
1249        let retry = work_indices(&m, true);
1250        assert_eq!(retry, vec![1]);
1251    }
1252
1253    #[test]
1254    fn manifest_roundtrip_via_transaction() {
1255        let dir = tempdir().unwrap();
1256        let fp = operation_fingerprint(&fp_input("tiny-q5_1"));
1257        let mut m = BatchManifest::new(
1258            "local",
1259            "tiny-q5_1",
1260            "en",
1261            OutputFormat::Json,
1262            dir.path(),
1263            Some("speed"),
1264            &fp,
1265        );
1266        m.items = build_items(&[PathBuf::from("/tmp/x.wav")], OutputFormat::Json);
1267        let path = manifest_path(dir.path());
1268        m.save(&path).unwrap();
1269        let loaded = BatchManifest::load(&path).unwrap();
1270        assert_eq!(loaded.model, "tiny-q5_1");
1271        assert_eq!(loaded.items.len(), 1);
1272        assert_eq!(loaded.profile.as_deref(), Some("speed"));
1273        assert_eq!(loaded.schema_version, 2);
1274        assert!(!loaded.run_id.is_empty());
1275    }
1276
1277    #[test]
1278    fn v1_manifest_rejected() {
1279        let dir = tempdir().unwrap();
1280        let path = manifest_path(dir.path());
1281        let v1 = r#"{
1282            "schema_version": 1,
1283            "aurum_version": "0.0.21",
1284            "created_at_unix": 1,
1285            "updated_at_unix": 1,
1286            "provider": "local",
1287            "model": "base",
1288            "language": "en",
1289            "output_format": "txt",
1290            "output_dir": "/tmp",
1291            "items": []
1292        }"#;
1293        fs::write(&path, v1).unwrap();
1294        let err = BatchManifest::load(&path).unwrap_err();
1295        let msg = err.to_string();
1296        assert!(msg.contains("schema v1"), "{msg}");
1297    }
1298
1299    #[test]
1300    fn resume_decision_stale_source() {
1301        let dir = tempdir().unwrap();
1302        let src = dir.path().join("a.wav");
1303        fs::write(&src, b"hello-audio").unwrap();
1304        let (digest, size) = sha256_file_full(&src).unwrap();
1305        let out = dir.path().join("a.txt");
1306        fs::write(&out, b"transcript").unwrap();
1307        let (od, os) = sha256_file_full(&out).unwrap();
1308        let fp = operation_fingerprint(&fp_input("base"));
1309        let item = BatchItem {
1310            id: "1".into(),
1311            source: src.display().to_string(),
1312            output: "a.txt".into(),
1313            status: BatchItemStatus::Succeeded,
1314            error: None,
1315            attempts: 1,
1316            source_sha256: Some(digest),
1317            source_size: Some(size),
1318            output_sha256: Some(od),
1319            output_size: Some(os),
1320            operation_fingerprint: Some(fp.clone()),
1321            model_digest: None,
1322            started_at_unix: None,
1323            finished_at_unix: None,
1324        };
1325        // Exact match
1326        let (d, _) = verify_item_for_resume(&item, dir.path(), &fp, false);
1327        assert_eq!(d, ResumeDecision::Reuse);
1328        // Change source after first byte
1329        fs::write(&src, b"HELLO-AUDIO").unwrap();
1330        let (d2, st) = verify_item_for_resume(&item, dir.path(), &fp, false);
1331        assert_eq!(d2, ResumeDecision::FailConfiguration);
1332        assert_eq!(st, Some(BatchItemStatus::StaleSource));
1333        let (d3, st3) = verify_item_for_resume(&item, dir.path(), &fp, true);
1334        assert_eq!(d3, ResumeDecision::Work);
1335        assert_eq!(st3, Some(BatchItemStatus::StaleSource));
1336    }
1337
1338    #[test]
1339    fn lock_exclusive() {
1340        let dir = tempdir().unwrap();
1341        let g1 = acquire_batch_lock(dir.path(), "run1").unwrap();
1342        assert!(acquire_batch_lock(dir.path(), "run2").is_err());
1343        drop(g1);
1344        let g2 = acquire_batch_lock(dir.path(), "run2").unwrap();
1345        drop(g2);
1346    }
1347
1348    #[test]
1349    fn running_becomes_interrupted_on_prepare() {
1350        let dir = tempdir().unwrap();
1351        let fp = operation_fingerprint(&fp_input("base"));
1352        let mut m = BatchManifest::new(
1353            "local",
1354            "base",
1355            "en",
1356            OutputFormat::Txt,
1357            dir.path(),
1358            None,
1359            &fp,
1360        );
1361        m.items.push(BatchItem {
1362            id: "1".into(),
1363            source: dir.path().join("missing.wav").display().to_string(),
1364            output: "a.txt".into(),
1365            status: BatchItemStatus::Running,
1366            error: None,
1367            attempts: 1,
1368            source_sha256: None,
1369            source_size: None,
1370            output_sha256: None,
1371            output_size: None,
1372            operation_fingerprint: Some(fp.clone()),
1373            model_digest: None,
1374            started_at_unix: None,
1375            finished_at_unix: None,
1376        });
1377        let work = prepare_resume(&mut m, &fp, true, true).unwrap();
1378        assert_eq!(work, vec![0]);
1379        assert_eq!(m.items[0].status, BatchItemStatus::Pending);
1380    }
1381}