1use 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
18pub const BATCH_MANIFEST_VERSION: u32 = 2;
20
21pub const BATCH_MANIFEST_VERSION_V1: u32 = 1;
23
24pub const BATCH_MANIFEST_NAME: &str = "aurum-batch-manifest.json";
26
27pub const BATCH_LOCK_NAME: &str = "aurum-batch.lock";
29
30pub const MAX_BATCH_ERROR_CHARS: usize = 512;
32
33pub const AUDIO_EXTENSIONS: &[&str] = &[
35 "wav", "mp3", "m4a", "flac", "ogg", "oga", "opus", "webm", "aac", "mp4", "mpeg", "mpga",
36];
37
38#[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 StaleSource,
53 StaleConfiguration,
55 StaleOutput,
57 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct BatchItem {
80 pub id: String,
82 pub source: String,
84 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 #[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#[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
147pub fn operation_fingerprint(input: &OperationFingerprintInput) -> String {
149 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#[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 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 pub output_dir: String,
231 pub operation_fingerprint: String,
233 #[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 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 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 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#[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
395pub 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
476pub 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
497pub 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
526pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
563pub enum ResumeDecision {
564 Reuse,
566 Work,
568 FailConfiguration,
570}
571
572pub 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 (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 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 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 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
698pub 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 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 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 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
791pub 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
811pub 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 }
845 Ok((hex::encode(hasher.finalize()), meta.len()))
846}
847
848pub 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(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
909pub 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#[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
943pub 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 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 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
985pub 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
1003pub fn validate_batch_stt_provider(
1009 registry: &ProviderRegistry,
1010 provider: &str,
1011) -> Result<ProviderId> {
1012 let id = ProviderId::parse(provider)?;
1013 registry.stt_factory(&id)?;
1015 Ok(id)
1016}
1017
1018#[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 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 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 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 let (d, _) = verify_item_for_resume(&item, dir.path(), &fp, false);
1237 assert_eq!(d, ResumeDecision::Reuse);
1238 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}