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")]
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 #[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
151pub fn operation_fingerprint(input: &OperationFingerprintInput) -> String {
153 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
205pub 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
220pub 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#[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 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 pub output_dir: String,
258 pub operation_fingerprint: String,
260 #[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 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 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 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#[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
422pub 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
503pub 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
524pub 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
553pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
590pub enum ResumeDecision {
591 Reuse,
593 Work,
595 FailConfiguration,
597}
598
599pub 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 (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 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 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 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
725pub 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 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 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 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
818pub 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
838pub 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
886pub 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
909pub 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(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
970pub 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#[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
1004pub 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 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 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
1046pub 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
1064pub fn validate_batch_stt_provider(
1070 registry: &ProviderRegistry,
1071 provider: &str,
1072) -> Result<ProviderId> {
1073 let id = ProviderId::parse(provider)?;
1074 registry.stt_factory(&id)?;
1076 Ok(id)
1077}
1078
1079#[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 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 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 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 let (d, _) = verify_item_for_resume(&item, dir.path(), &fp, false);
1327 assert_eq!(d, ResumeDecision::Reuse);
1328 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}