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