Skip to main content

kcode_audio_ingress/
lib.rs

1//! Durable, automatic audio transcription.
2//!
3//! [`AudioIngress`] accepts owned WAV bytes, persists them before returning,
4//! and automatically transcribes them in the background.
5
6#![deny(missing_docs)]
7#![forbid(unsafe_code)]
8
9use std::{
10    collections::HashMap,
11    fs,
12    path::{Path, PathBuf},
13    sync::{Arc, Mutex, Weak},
14    time::Duration,
15};
16
17use anyhow::{Context, ensure};
18use chrono::{DateTime, Duration as ChronoDuration, Utc};
19use rusqlite::{Connection, OptionalExtension, params};
20use serde::Serialize;
21use serde_json::Value;
22use sha2::{Digest, Sha256};
23use tokio::io::AsyncWriteExt;
24pub use transcribe::{
25    AudioTranscriber, JobState, RECONCILIATION_MODEL, RECONCILIATION_REASONING, Step, StepError,
26    StepState, StepStatus, TRANSCRIPTION_MODEL, TranscriptionJob, TranscriptionStatus,
27};
28use transcribe::{ChunkPlan, ChunkTranscript, PIECE_CACHE_REVISION, PieceCache, PieceSink};
29use uuid::Uuid;
30
31mod transcribe;
32
33const INITIAL_MIGRATION: &str = include_str!("../migrations/001_initial.sql");
34const RELEASE_DEFERRED_INGRESS_MIGRATION: &str =
35    include_str!("../migrations/002_release_deferred_ingress.sql");
36const TRANSCRIPTION_STATUS_MIGRATION: &str =
37    include_str!("../migrations/003_transcription_status.sql");
38const RETRY_ROUNDED_WAV_INTERVALS_MIGRATION: &str =
39    include_str!("../migrations/004_retry_rounded_wav_intervals.sql");
40const UNIFIED_INGRESS_QUEUE_MIGRATION: &str =
41    include_str!("../migrations/005_unified_ingress_queue.sql");
42const STANDALONE_LIBRARY_MIGRATION: &str = include_str!("../migrations/006_standalone_library.sql");
43const DURABLE_TRANSCRIPT_PIECES_MIGRATION: &str =
44    include_str!("../migrations/007_durable_transcript_pieces.sql");
45const UNIQUE_TRANSCRIPTION_ATTEMPTS_MIGRATION: &str =
46    include_str!("../migrations/008_unique_transcription_attempts.sql");
47
48const DATABASE_FILENAME: &str = "state.sqlite3";
49const ORIGINALS_DIRECTORY: &str = "originals";
50const FAILURE_LIMIT: i64 = 5;
51const RETRY_DELAY_SECONDS: i64 = 15;
52const LATEST_SCHEMA_VERSION: i64 = 8;
53
54const FRESH_SCHEMA: &str = r#"
55CREATE TABLE audio_recordings (
56    id TEXT PRIMARY KEY NOT NULL,
57    sha256 TEXT NOT NULL UNIQUE CHECK(length(sha256) = 64),
58    original_filename TEXT NOT NULL,
59    content_type TEXT NOT NULL,
60    size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0),
61    source_created_at TEXT NOT NULL,
62    received_at TEXT NOT NULL,
63    updated_at TEXT NOT NULL,
64    original_relative_path TEXT NOT NULL,
65    status TEXT NOT NULL CHECK(status IN (
66        'uploaded', 'chunking', 'transcribing', 'reconciling',
67        'ready_for_ingress', 'ingressing', 'ingress_failed', 'complete', 'failed'
68    )),
69    gemini_model TEXT NOT NULL,
70    reconciliation_model TEXT NOT NULL,
71    reconciliation_reasoning TEXT NOT NULL,
72    final_transcript TEXT,
73    attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count >= 0),
74    next_attempt_at TEXT,
75    last_error TEXT,
76    transcription_status_json TEXT,
77    failure_retryable INTEGER NOT NULL DEFAULT 1
78        CHECK(failure_retryable IN (0, 1))
79);
80
81CREATE INDEX audio_recordings_work_queue
82ON audio_recordings(status, next_attempt_at, received_at);
83
84CREATE TABLE audio_transcript_pieces (
85    recording_id TEXT NOT NULL REFERENCES audio_recordings(id) ON DELETE CASCADE,
86    attempt_id TEXT NOT NULL CHECK(length(attempt_id) > 0),
87    cache_revision TEXT NOT NULL CHECK(length(cache_revision) > 0),
88    piece_index INTEGER NOT NULL CHECK(piece_index >= 0),
89    piece_count INTEGER NOT NULL CHECK(piece_count > 0 AND piece_index < piece_count),
90    audio_start_ms INTEGER NOT NULL CHECK(audio_start_ms >= 0),
91    audio_end_ms INTEGER NOT NULL CHECK(audio_end_ms > audio_start_ms),
92    transcript_json TEXT NOT NULL,
93    created_at TEXT NOT NULL,
94    PRIMARY KEY(recording_id, attempt_id, piece_index)
95);
96
97CREATE INDEX audio_transcript_pieces_cache_lookup
98ON audio_transcript_pieces(
99    recording_id,
100    cache_revision,
101    piece_index,
102    piece_count,
103    audio_start_ms,
104    audio_end_ms,
105    created_at
106);
107
108PRAGMA user_version = 8;
109"#;
110
111/// Owned audio and its source metadata.
112#[derive(Clone, Debug)]
113pub struct AudioInput {
114    /// Complete WAV bytes.
115    pub bytes: Vec<u8>,
116    /// Instant at which recording began.
117    pub recorded_at: DateTime<Utc>,
118    /// Original leaf filename, when known.
119    pub original_filename: Option<String>,
120}
121
122/// Result of durably submitting audio.
123#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
124pub struct Submission {
125    /// Stable recording identifier.
126    pub recording_id: Uuid,
127    /// Whether the same audio bytes were already known.
128    pub deduplicated: bool,
129}
130
131/// Complete current library state.
132#[derive(Clone, Debug, Serialize)]
133pub struct Status {
134    /// Recordings ordered by recording time, newest first.
135    pub recordings: Vec<RecordingStatus>,
136}
137
138/// Current state and immutable identity of one recording.
139#[derive(Clone, Debug, Serialize)]
140pub struct RecordingStatus {
141    /// Stable recording identifier.
142    pub id: Uuid,
143    /// Lowercase SHA-256 digest of the original bytes.
144    pub sha256: String,
145    /// Sanitized original filename.
146    pub original_filename: String,
147    /// Original byte length.
148    pub size_bytes: u64,
149    /// Instant at which recording began.
150    pub recorded_at: DateTime<Utc>,
151    /// Instant at which AudioIngress accepted the recording.
152    pub received_at: DateTime<Utc>,
153    /// Gemini transcription model attribution.
154    pub transcription_model: String,
155    /// Codex reconciliation model attribution.
156    pub reconciliation_model: String,
157    /// Codex reconciliation reasoning attribution.
158    pub reconciliation_reasoning: String,
159    /// Current processing state.
160    pub state: RecordingState,
161}
162
163/// Automatic processing state of one recording.
164#[derive(Clone, Debug, Serialize)]
165#[serde(tag = "kind", rename_all = "snake_case")]
166pub enum RecordingState {
167    /// Persisted and waiting for automatic processing.
168    Queued,
169    /// An in-memory transcription attempt is active.
170    Processing {
171        /// One-based full-job attempt number.
172        attempt: u8,
173        /// Current dependency status. Its transcript is always omitted.
174        progress: TranscriptionStatus,
175    },
176    /// The canonical transcript is durable.
177    Complete {
178        /// Canonical reconciled Markdown.
179        transcript: String,
180    },
181    /// Automatic processing stopped.
182    Failed {
183        /// Attempts used in the current manual-attempt budget.
184        attempts: u8,
185        /// Concise diagnostic.
186        error: String,
187        /// Whether another attempt can reasonably succeed.
188        retryable: bool,
189    },
190}
191
192/// Stable library error category.
193#[derive(Clone, Copy, Debug, Eq, PartialEq)]
194pub enum ErrorKind {
195    /// Supplied data is invalid.
196    InvalidInput,
197    /// The requested recording does not exist.
198    NotFound,
199    /// The requested transition is not valid in the current state.
200    Conflict,
201    /// Persistence or internal processing failed unexpectedly.
202    Internal,
203}
204
205/// Error returned by the minimal AudioIngress API.
206#[derive(Debug)]
207pub struct Error {
208    kind: ErrorKind,
209    message: String,
210}
211
212impl Error {
213    fn invalid(message: impl Into<String>) -> Self {
214        Self {
215            kind: ErrorKind::InvalidInput,
216            message: message.into(),
217        }
218    }
219
220    fn not_found() -> Self {
221        Self {
222            kind: ErrorKind::NotFound,
223            message: "Audio recording not found.".into(),
224        }
225    }
226
227    fn conflict(message: impl Into<String>) -> Self {
228        Self {
229            kind: ErrorKind::Conflict,
230            message: message.into(),
231        }
232    }
233
234    fn internal(error: impl std::fmt::Display) -> Self {
235        tracing::error!(%error, "AudioIngress operation failed");
236        Self {
237            kind: ErrorKind::Internal,
238            message: "An unexpected AudioIngress error occurred.".into(),
239        }
240    }
241
242    /// Returns the stable error category.
243    pub fn kind(&self) -> ErrorKind {
244        self.kind
245    }
246}
247
248impl std::fmt::Display for Error {
249    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        formatter.write_str(&self.message)
251    }
252}
253
254impl std::error::Error for Error {}
255
256struct TemporaryUpload(PathBuf);
257
258impl Drop for TemporaryUpload {
259    fn drop(&mut self) {
260        let _ = fs::remove_file(&self.0);
261    }
262}
263
264struct Inner {
265    root: PathBuf,
266    db: Arc<Mutex<Connection>>,
267    transcriber: AudioTranscriber,
268    jobs: Mutex<HashMap<Uuid, TranscriptionJob>>,
269}
270
271/// Cloneable handle to durable, automatically processed audio.
272#[derive(Clone)]
273pub struct AudioIngress {
274    inner: Arc<Inner>,
275}
276
277impl AudioIngress {
278    /// Opens the owned persistence root and starts automatic processing.
279    pub async fn open(
280        persistence_root: impl AsRef<Path>,
281        transcriber: AudioTranscriber,
282    ) -> Result<Self, Error> {
283        let root = persistence_root.as_ref().to_path_buf();
284        ensure_private_directory(&root).map_err(Error::internal)?;
285        ensure_private_directory(&root.join(ORIGINALS_DIRECTORY)).map_err(Error::internal)?;
286        let connection = Connection::open(root.join(DATABASE_FILENAME)).map_err(Error::internal)?;
287        connection
288            .execute_batch(
289                "PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;",
290            )
291            .map_err(Error::internal)?;
292        apply_migrations(&connection).map_err(Error::internal)?;
293        recover_interrupted_attempts(&connection).map_err(Error::internal)?;
294
295        let inner = Arc::new(Inner {
296            root,
297            db: Arc::new(Mutex::new(connection)),
298            transcriber,
299            jobs: Mutex::new(HashMap::new()),
300        });
301        tokio::spawn(worker_loop(Arc::downgrade(&inner)));
302        Ok(Self { inner })
303    }
304
305    /// Durably accepts complete WAV bytes and schedules automatic processing.
306    pub async fn submit(&self, input: AudioInput) -> Result<Submission, Error> {
307        if input.bytes.is_empty() {
308            return Err(Error::invalid("Audio bytes must not be empty."));
309        }
310        let size_bytes = i64::try_from(input.bytes.len())
311            .map_err(|_| Error::invalid("Audio is too large for this platform."))?;
312        let sha256 = format!("{:x}", Sha256::digest(&input.bytes));
313        if let Some(id) = self.recording_id_by_sha(&sha256)? {
314            return Ok(Submission {
315                recording_id: id,
316                deduplicated: true,
317            });
318        }
319
320        let upload_id = Uuid::new_v4();
321        let temporary = TemporaryUpload(self.inner.root.join(format!(".upload-{upload_id}.tmp")));
322        let mut file = tokio::fs::OpenOptions::new()
323            .write(true)
324            .create_new(true)
325            .open(&temporary.0)
326            .await
327            .map_err(Error::internal)?;
328        set_private_file(&temporary.0).map_err(Error::internal)?;
329        file.write_all(&input.bytes)
330            .await
331            .map_err(Error::internal)?;
332        file.sync_all().await.map_err(Error::internal)?;
333        drop(file);
334
335        let relative_path = format!("{ORIGINALS_DIRECTORY}/{sha256}.wav");
336        let final_path = self.inner.root.join(&relative_path);
337        if final_path.exists() {
338            tokio::fs::remove_file(&temporary.0)
339                .await
340                .map_err(Error::internal)?;
341        } else {
342            tokio::fs::rename(&temporary.0, &final_path)
343                .await
344                .map_err(Error::internal)?;
345        }
346        set_private_file(&final_path).map_err(Error::internal)?;
347        sync_file(&final_path).map_err(Error::internal)?;
348        sync_directory(final_path.parent().unwrap_or(&self.inner.root)).map_err(Error::internal)?;
349        sync_directory(&self.inner.root).map_err(Error::internal)?;
350
351        let id = Uuid::new_v4();
352        let now = Utc::now().to_rfc3339();
353        let filename = safe_filename(input.original_filename.as_deref());
354        let insert = {
355            let db = self.inner.db.lock().map_err(Error::internal)?;
356            db.execute(
357                "INSERT INTO audio_recordings(
358                    id,sha256,original_filename,content_type,size_bytes,
359                    source_created_at,received_at,updated_at,original_relative_path,
360                    status,gemini_model,reconciliation_model,reconciliation_reasoning
361                 ) VALUES(?1,?2,?3,'audio/wav',?4,?5,?6,?6,?7,'uploaded',?8,?9,?10)",
362                params![
363                    id.to_string(),
364                    sha256,
365                    filename,
366                    size_bytes,
367                    input.recorded_at.to_rfc3339(),
368                    now,
369                    relative_path,
370                    TRANSCRIPTION_MODEL,
371                    RECONCILIATION_MODEL,
372                    RECONCILIATION_REASONING,
373                ],
374            )
375        };
376        if let Err(error) = insert {
377            if let Some(existing) = self.recording_id_by_sha(&sha256)? {
378                return Ok(Submission {
379                    recording_id: existing,
380                    deduplicated: true,
381                });
382            }
383            return Err(Error::internal(error));
384        }
385        tracing::info!(recording_id=%id, %sha256, bytes=size_bytes, "Durably accepted audio");
386        Ok(Submission {
387            recording_id: id,
388            deduplicated: false,
389        })
390    }
391
392    /// Returns all current recording states in one snapshot.
393    pub fn status(&self) -> Result<Status, Error> {
394        let db = self.inner.db.lock().map_err(Error::internal)?;
395        let mut statement = db
396            .prepare(
397                "SELECT id,sha256,original_filename,size_bytes,source_created_at,received_at,
398                        status,gemini_model,reconciliation_model,reconciliation_reasoning,
399                        attempt_count,last_error,failure_retryable,transcription_status_json,
400                        final_transcript
401                 FROM audio_recordings
402                 ORDER BY datetime(source_created_at) DESC,datetime(received_at) DESC,id DESC",
403            )
404            .map_err(Error::internal)?;
405        let recordings = statement
406            .query_map([], row_recording_status)
407            .map_err(Error::internal)?
408            .collect::<Result<Vec<_>, _>>()
409            .map_err(Error::internal)?;
410        Ok(Status { recordings })
411    }
412
413    /// Gives a failed recording a fresh five-attempt processing budget.
414    pub fn retry(&self, recording_id: Uuid) -> Result<(), Error> {
415        let db = self.inner.db.lock().map_err(Error::internal)?;
416        let changed = db
417            .execute(
418                "UPDATE audio_recordings
419                 SET status='uploaded',attempt_count=0,next_attempt_at=NULL,last_error=NULL,
420                     transcription_status_json=NULL,failure_retryable=1,updated_at=?1
421                 WHERE id=?2 AND status='failed'",
422                params![Utc::now().to_rfc3339(), recording_id.to_string()],
423            )
424            .map_err(Error::internal)?;
425        if changed == 1 {
426            return Ok(());
427        }
428        let exists = db
429            .query_row(
430                "SELECT 1 FROM audio_recordings WHERE id=?1",
431                [recording_id.to_string()],
432                |row| row.get::<_, i64>(0),
433            )
434            .optional()
435            .map_err(Error::internal)?
436            .is_some();
437        if exists {
438            Err(Error::conflict("Only a failed recording can be retried."))
439        } else {
440            Err(Error::not_found())
441        }
442    }
443
444    fn recording_id_by_sha(&self, sha256: &str) -> Result<Option<Uuid>, Error> {
445        let db = self.inner.db.lock().map_err(Error::internal)?;
446        db.query_row(
447            "SELECT id FROM audio_recordings WHERE sha256=?1",
448            [sha256],
449            |row| row.get::<_, String>(0),
450        )
451        .optional()
452        .map_err(Error::internal)?
453        .map(|id| Uuid::parse_str(&id).map_err(Error::internal))
454        .transpose()
455    }
456}
457
458fn row_recording_status(row: &rusqlite::Row<'_>) -> rusqlite::Result<RecordingStatus> {
459    let id: String = row.get(0)?;
460    let recorded_at: String = row.get(4)?;
461    let received_at: String = row.get(5)?;
462    let durable_status: String = row.get(6)?;
463    let attempts: i64 = row.get(10)?;
464    let last_error: Option<String> = row.get(11)?;
465    let retryable: i64 = row.get(12)?;
466    let progress_json: Option<String> = row.get(13)?;
467    let transcript: Option<String> = row.get(14)?;
468    let parse_time = |index, value: &str| {
469        DateTime::parse_from_rfc3339(value)
470            .map(|value| value.with_timezone(&Utc))
471            .map_err(|error| {
472                rusqlite::Error::FromSqlConversionFailure(
473                    index,
474                    rusqlite::types::Type::Text,
475                    Box::new(error),
476                )
477            })
478    };
479    let state = match durable_status.as_str() {
480        "uploaded" => RecordingState::Queued,
481        "chunking" | "transcribing" | "reconciling" => {
482            let progress = progress_json
483                .as_deref()
484                .map(serde_json::from_str)
485                .transpose()
486                .map_err(|error| {
487                    rusqlite::Error::FromSqlConversionFailure(
488                        13,
489                        rusqlite::types::Type::Text,
490                        Box::new(error),
491                    )
492                })?
493                .unwrap_or_else(initial_progress);
494            RecordingState::Processing {
495                attempt: attempts.clamp(0, i64::from(u8::MAX)) as u8,
496                progress: without_transcript(progress),
497            }
498        }
499        "ready_for_ingress" | "ingressing" | "ingress_failed" | "complete" => {
500            RecordingState::Complete {
501                transcript: transcript.unwrap_or_default(),
502            }
503        }
504        "failed" => RecordingState::Failed {
505            attempts: attempts.clamp(0, i64::from(u8::MAX)) as u8,
506            error: last_error.unwrap_or_else(|| "Audio processing failed.".into()),
507            retryable: retryable != 0,
508        },
509        other => {
510            return Err(rusqlite::Error::FromSqlConversionFailure(
511                6,
512                rusqlite::types::Type::Text,
513                format!("unknown audio status {other:?}").into(),
514            ));
515        }
516    };
517    Ok(RecordingStatus {
518        id: Uuid::parse_str(&id).map_err(|error| {
519            rusqlite::Error::FromSqlConversionFailure(
520                0,
521                rusqlite::types::Type::Text,
522                Box::new(error),
523            )
524        })?,
525        sha256: row.get(1)?,
526        original_filename: row.get(2)?,
527        size_bytes: u64::try_from(row.get::<_, i64>(3)?).map_err(|error| {
528            rusqlite::Error::FromSqlConversionFailure(
529                3,
530                rusqlite::types::Type::Integer,
531                Box::new(error),
532            )
533        })?,
534        recorded_at: parse_time(4, &recorded_at)?,
535        received_at: parse_time(5, &received_at)?,
536        transcription_model: row.get(7)?,
537        reconciliation_model: row.get(8)?,
538        reconciliation_reasoning: row.get(9)?,
539        state,
540    })
541}
542
543async fn worker_loop(inner: Weak<Inner>) {
544    loop {
545        let Some(inner) = inner.upgrade() else {
546            return;
547        };
548        let worked = match process_next_recording(&inner).await {
549            Ok(worked) => worked,
550            Err(error) => {
551                tracing::error!(error=%error, "AudioIngress worker iteration failed");
552                false
553            }
554        };
555        drop(inner);
556        tokio::time::sleep(if worked {
557            Duration::from_millis(100)
558        } else {
559            Duration::from_secs(5)
560        })
561        .await;
562    }
563}
564
565#[derive(Debug)]
566struct WorkRecording {
567    id: Uuid,
568    original_relative_path: String,
569    attempt_count: i64,
570}
571
572async fn process_next_recording(inner: &Inner) -> anyhow::Result<bool> {
573    let recording = {
574        let db = inner
575            .db
576            .lock()
577            .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
578        fetch_work_recording(&db)?
579    };
580    let Some(recording) = recording else {
581        return Ok(false);
582    };
583    poll_transcription(inner, recording).await?;
584    Ok(true)
585}
586
587fn fetch_work_recording(db: &Connection) -> anyhow::Result<Option<WorkRecording>> {
588    db.query_row(
589        "SELECT id,original_relative_path,attempt_count
590         FROM audio_recordings
591         WHERE status IN ('uploaded','chunking','transcribing','reconciling')
592           AND (next_attempt_at IS NULL OR datetime(next_attempt_at)<=datetime('now'))
593         ORDER BY datetime(received_at),id
594         LIMIT 1",
595        [],
596        |row| {
597            let id: String = row.get(0)?;
598            Ok((id, row.get(1)?, row.get(2)?))
599        },
600    )
601    .optional()?
602    .map(|(id, original_relative_path, attempt_count)| {
603        Ok(WorkRecording {
604            id: Uuid::parse_str(&id)?,
605            original_relative_path,
606            attempt_count,
607        })
608    })
609    .transpose()
610}
611
612async fn poll_transcription(inner: &Inner, mut recording: WorkRecording) -> anyhow::Result<()> {
613    let existing = inner
614        .jobs
615        .lock()
616        .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
617        .get(&recording.id)
618        .cloned();
619    let job = if let Some(job) = existing {
620        job
621    } else {
622        if recording.attempt_count >= FAILURE_LIMIT {
623            mark_failed(
624                inner,
625                recording.id,
626                recording.attempt_count,
627                true,
628                "Audio transcription exhausted its five automatic attempts.",
629                None,
630            )?;
631            return Ok(());
632        }
633        let source = inner.root.join(&recording.original_relative_path);
634        let audio = tokio::fs::read(&source)
635            .await
636            .with_context(|| format!("reading retained audio {}", source.display()))?;
637        recording.attempt_count += 1;
638        {
639            let db = inner
640                .db
641                .lock()
642                .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
643            db.execute(
644                "UPDATE audio_recordings
645                 SET status='chunking',attempt_count=?1,next_attempt_at=NULL,last_error=NULL,
646                     failure_retryable=1,updated_at=?2
647                 WHERE id=?3",
648                params![
649                    recording.attempt_count,
650                    Utc::now().to_rfc3339(),
651                    recording.id.to_string()
652                ],
653            )?;
654        }
655
656        let cache_db = inner.db.clone();
657        let cache_recording_id = recording.id;
658        let piece_cache: PieceCache =
659            Arc::new(move |plan| load_cached_transcript_piece(&cache_db, cache_recording_id, plan));
660
661        let sink_db = inner.db.clone();
662        let sink_recording_id = recording.id;
663        let attempt_id = Uuid::new_v4().to_string();
664        let piece_sink: PieceSink = Arc::new(move |piece| {
665            persist_transcript_piece(&sink_db, sink_recording_id, &attempt_id, piece)
666        });
667
668        let job = inner
669            .transcriber
670            .transcribe_durably(audio, piece_cache, piece_sink);
671        inner
672            .jobs
673            .lock()
674            .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
675            .insert(recording.id, job.clone());
676        job
677    };
678
679    let snapshot = job.status();
680    persist_progress(inner, recording.id, &snapshot)?;
681    match snapshot.state {
682        JobState::Queued | JobState::Running => Ok(()),
683        JobState::Completed => {
684            remove_job(inner, recording.id)?;
685            let transcript = snapshot
686                .transcript
687                .clone()
688                .context("completed transcription omitted its transcript")?;
689            ensure!(
690                !transcript.trim().is_empty(),
691                "completed transcription is empty"
692            );
693            let progress = serde_json::to_string(&without_transcript(snapshot))?;
694            let db = inner
695                .db
696                .lock()
697                .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
698            db.execute(
699                "UPDATE audio_recordings
700                 SET status='ready_for_ingress',final_transcript=?1,
701                     transcription_status_json=?2,next_attempt_at=NULL,last_error=NULL,
702                     failure_retryable=1,updated_at=?3
703                 WHERE id=?4",
704                params![
705                    transcript.trim(),
706                    progress,
707                    Utc::now().to_rfc3339(),
708                    recording.id.to_string()
709                ],
710            )?;
711            tracing::info!(recording_id=%recording.id, "Audio transcript completed");
712            Ok(())
713        }
714        JobState::Failed => {
715            remove_job(inner, recording.id)?;
716            let error = snapshot
717                .steps
718                .iter()
719                .find(|step| step.state == StepState::Failed)
720                .and_then(|step| step.error.as_ref());
721            let message = error
722                .map(|error| error.message.clone())
723                .unwrap_or_else(|| "Audio transcription failed without detail.".into());
724            let retryable = error.is_none_or(|error| error.retryable);
725            record_attempt_failure(
726                inner,
727                recording.id,
728                recording.attempt_count,
729                retryable,
730                &message,
731                Some(snapshot),
732            )
733        }
734    }
735}
736
737fn record_attempt_failure(
738    inner: &Inner,
739    id: Uuid,
740    attempts: i64,
741    retryable: bool,
742    message: &str,
743    progress: Option<TranscriptionStatus>,
744) -> anyhow::Result<()> {
745    if !retryable || attempts >= FAILURE_LIMIT {
746        return mark_failed(inner, id, attempts, retryable, message, progress);
747    }
748    let db = inner
749        .db
750        .lock()
751        .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
752    db.execute(
753        "UPDATE audio_recordings
754         SET status='uploaded',next_attempt_at=?1,last_error=?2,failure_retryable=1,
755             transcription_status_json=?3,updated_at=?4
756         WHERE id=?5",
757        params![
758            (Utc::now() + ChronoDuration::seconds(RETRY_DELAY_SECONDS)).to_rfc3339(),
759            concise(message, 2_000),
760            progress
761                .map(without_transcript)
762                .map(|progress| serde_json::to_string(&progress))
763                .transpose()?,
764            Utc::now().to_rfc3339(),
765            id.to_string()
766        ],
767    )?;
768    tracing::warn!(recording_id=%id, attempt=attempts, "Audio transcription will retry");
769    Ok(())
770}
771
772fn mark_failed(
773    inner: &Inner,
774    id: Uuid,
775    attempts: i64,
776    retryable: bool,
777    message: &str,
778    progress: Option<TranscriptionStatus>,
779) -> anyhow::Result<()> {
780    let db = inner
781        .db
782        .lock()
783        .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
784    db.execute(
785        "UPDATE audio_recordings
786         SET status='failed',attempt_count=?1,next_attempt_at=NULL,last_error=?2,
787             failure_retryable=?3,transcription_status_json=?4,updated_at=?5
788         WHERE id=?6",
789        params![
790            attempts,
791            concise(message, 2_000),
792            i64::from(retryable),
793            progress
794                .map(without_transcript)
795                .map(|progress| serde_json::to_string(&progress))
796                .transpose()?,
797            Utc::now().to_rfc3339(),
798            id.to_string()
799        ],
800    )?;
801    tracing::error!(recording_id=%id, attempts, retryable, "Audio transcription stopped");
802    Ok(())
803}
804
805fn persist_progress(inner: &Inner, id: Uuid, snapshot: &TranscriptionStatus) -> anyhow::Result<()> {
806    let durable_status = transcription_stage(snapshot);
807    let serialized = serde_json::to_string(&without_transcript(snapshot.clone()))?;
808    let db = inner
809        .db
810        .lock()
811        .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
812    db.execute(
813        "UPDATE audio_recordings
814         SET status=?1,transcription_status_json=?2,updated_at=?3
815         WHERE id=?4 AND (status<>?1 OR COALESCE(transcription_status_json,'')<>?2)",
816        params![
817            durable_status,
818            serialized,
819            Utc::now().to_rfc3339(),
820            id.to_string()
821        ],
822    )?;
823    Ok(())
824}
825
826fn load_cached_transcript_piece(
827    db: &Mutex<Connection>,
828    recording_id: Uuid,
829    plan: ChunkPlan,
830) -> anyhow::Result<Option<String>> {
831    let piece_index = i64::try_from(plan.index).context("piece index exceeds SQLite limits")?;
832    let piece_count = i64::try_from(plan.total).context("piece count exceeds SQLite limits")?;
833    let audio_start_ms =
834        i64::try_from(plan.start_ms).context("piece start exceeds SQLite limits")?;
835    let audio_end_ms = i64::try_from(plan.end_ms).context("piece end exceeds SQLite limits")?;
836    let transcript = {
837        let db = db
838            .lock()
839            .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
840        db.query_row(
841            "SELECT transcript_json
842             FROM audio_transcript_pieces
843             WHERE recording_id=?1
844               AND cache_revision=?2
845               AND piece_index=?3
846               AND piece_count=?4
847               AND audio_start_ms=?5
848               AND audio_end_ms=?6
849             ORDER BY datetime(created_at) DESC,attempt_id DESC
850             LIMIT 1",
851            params![
852                recording_id.to_string(),
853                PIECE_CACHE_REVISION,
854                piece_index,
855                piece_count,
856                audio_start_ms,
857                audio_end_ms,
858            ],
859            |row| row.get::<_, String>(0),
860        )
861        .optional()?
862    };
863    if let Some(value) = transcript.as_deref() {
864        let parsed: Value =
865            serde_json::from_str(value).context("cached piece transcript is invalid JSON")?;
866        ensure!(
867            parsed.get("utterances").and_then(Value::as_array).is_some(),
868            "cached piece transcript omitted the utterances array"
869        );
870    }
871    Ok(transcript)
872}
873
874fn persist_transcript_piece(
875    db: &Mutex<Connection>,
876    recording_id: Uuid,
877    attempt_id: &str,
878    piece: &ChunkTranscript,
879) -> anyhow::Result<()> {
880    ensure!(!attempt_id.is_empty(), "piece attempt identity is empty");
881    let piece_index =
882        i64::try_from(piece.plan.index).context("piece index exceeds SQLite limits")?;
883    let piece_count =
884        i64::try_from(piece.plan.total).context("piece count exceeds SQLite limits")?;
885    let audio_start_ms =
886        i64::try_from(piece.plan.start_ms).context("piece start exceeds SQLite limits")?;
887    let audio_end_ms =
888        i64::try_from(piece.plan.end_ms).context("piece end exceeds SQLite limits")?;
889    let db = db
890        .lock()
891        .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
892    db.execute(
893        "INSERT INTO audio_transcript_pieces(
894            recording_id,attempt_id,cache_revision,piece_index,piece_count,
895            audio_start_ms,audio_end_ms,transcript_json,created_at
896         ) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9)
897         ON CONFLICT(recording_id,attempt_id,piece_index) DO UPDATE SET
898            cache_revision=excluded.cache_revision,
899            piece_count=excluded.piece_count,
900            audio_start_ms=excluded.audio_start_ms,
901            audio_end_ms=excluded.audio_end_ms,
902            transcript_json=excluded.transcript_json",
903        params![
904            recording_id.to_string(),
905            attempt_id,
906            PIECE_CACHE_REVISION,
907            piece_index,
908            piece_count,
909            audio_start_ms,
910            audio_end_ms,
911            piece.transcript,
912            Utc::now().to_rfc3339(),
913        ],
914    )?;
915    Ok(())
916}
917
918fn remove_job(inner: &Inner, id: Uuid) -> anyhow::Result<()> {
919    inner
920        .jobs
921        .lock()
922        .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
923        .remove(&id);
924    Ok(())
925}
926
927fn transcription_stage(snapshot: &TranscriptionStatus) -> &'static str {
928    let plan_complete = snapshot
929        .steps
930        .iter()
931        .any(|entry| entry.step == Step::PlanChunks && entry.state == StepState::Completed);
932    if !plan_complete {
933        return "chunking";
934    }
935    let chunks_complete = snapshot
936        .steps
937        .iter()
938        .filter(|entry| matches!(entry.step, Step::TranscribeChunk { .. }))
939        .all(|entry| entry.state == StepState::Completed);
940    if chunks_complete {
941        "reconciling"
942    } else {
943        "transcribing"
944    }
945}
946
947fn without_transcript(mut status: TranscriptionStatus) -> TranscriptionStatus {
948    status.transcript = None;
949    status
950}
951
952fn initial_progress() -> TranscriptionStatus {
953    TranscriptionStatus {
954        state: JobState::Queued,
955        steps: Vec::new(),
956        transcript: None,
957    }
958}
959
960fn recover_interrupted_attempts(connection: &Connection) -> rusqlite::Result<()> {
961    connection.execute(
962        "UPDATE audio_recordings
963         SET status=CASE WHEN attempt_count>=?1 THEN 'failed' ELSE 'uploaded' END,
964             next_attempt_at=NULL,
965             last_error=CASE WHEN attempt_count>=?1
966                 THEN 'Audio transcription stopped after its fifth attempt was interrupted.'
967                 ELSE 'Audio transcription was interrupted and will restart automatically.'
968             END,
969             failure_retryable=1,
970             updated_at=?2
971         WHERE status IN ('chunking','transcribing','reconciling')",
972        params![FAILURE_LIMIT, Utc::now().to_rfc3339()],
973    )?;
974    Ok(())
975}
976
977fn apply_migrations(connection: &Connection) -> anyhow::Result<()> {
978    let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?;
979    ensure!(
980        version <= LATEST_SCHEMA_VERSION,
981        "audio-ingress database schema version {version} is newer than supported version {LATEST_SCHEMA_VERSION}"
982    );
983    if version == 0 {
984        let has_recordings = connection.query_row(
985            "SELECT EXISTS(
986                SELECT 1 FROM sqlite_schema
987                WHERE type='table' AND name='audio_recordings'
988             )",
989            [],
990            |row| row.get::<_, i64>(0),
991        )? == 1;
992        if !has_recordings {
993            connection.execute_batch(FRESH_SCHEMA)?;
994            return Ok(());
995        }
996    }
997    if version < 1 {
998        connection.execute_batch(INITIAL_MIGRATION)?;
999    }
1000    if version < 2 {
1001        connection.execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)?;
1002    }
1003    if version < 3 {
1004        connection.execute_batch(TRANSCRIPTION_STATUS_MIGRATION)?;
1005    }
1006    if version < 4 {
1007        connection.execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)?;
1008    }
1009    if version < 5 {
1010        connection.execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)?;
1011    }
1012    if version < 6 {
1013        connection.execute_batch(STANDALONE_LIBRARY_MIGRATION)?;
1014    }
1015    if version < 7 {
1016        connection.execute_batch(DURABLE_TRANSCRIPT_PIECES_MIGRATION)?;
1017    }
1018    if version < 8 {
1019        connection.execute_batch(UNIQUE_TRANSCRIPTION_ATTEMPTS_MIGRATION)?;
1020    }
1021    Ok(())
1022}
1023
1024fn ensure_private_directory(path: &Path) -> anyhow::Result<()> {
1025    fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
1026    #[cfg(unix)]
1027    {
1028        use std::os::unix::fs::PermissionsExt;
1029        fs::set_permissions(path, fs::Permissions::from_mode(0o700))
1030            .with_context(|| format!("setting private permissions on {}", path.display()))?;
1031    }
1032    Ok(())
1033}
1034
1035fn set_private_file(path: &Path) -> anyhow::Result<()> {
1036    #[cfg(unix)]
1037    {
1038        use std::os::unix::fs::PermissionsExt;
1039        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
1040            .with_context(|| format!("setting private permissions on {}", path.display()))?;
1041    }
1042    Ok(())
1043}
1044
1045fn sync_file(path: &Path) -> anyhow::Result<()> {
1046    fs::OpenOptions::new()
1047        .read(true)
1048        .write(true)
1049        .open(path)
1050        .with_context(|| format!("opening {} for sync", path.display()))?
1051        .sync_all()
1052        .with_context(|| format!("syncing {}", path.display()))
1053}
1054
1055fn sync_directory(path: &Path) -> anyhow::Result<()> {
1056    #[cfg(unix)]
1057    fs::File::open(path)
1058        .with_context(|| format!("opening directory {} for sync", path.display()))?
1059        .sync_all()
1060        .with_context(|| format!("syncing directory {}", path.display()))?;
1061    Ok(())
1062}
1063
1064fn safe_filename(value: Option<&str>) -> String {
1065    let name = value
1066        .and_then(|value| Path::new(value).file_name())
1067        .and_then(|value| value.to_str())
1068        .unwrap_or("audio.wav");
1069    let clean = name
1070        .chars()
1071        .map(|character| {
1072            if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
1073                character
1074            } else {
1075                '_'
1076            }
1077        })
1078        .take(200)
1079        .collect::<String>();
1080    if clean.is_empty() {
1081        "audio.wav".into()
1082    } else {
1083        clean
1084    }
1085}
1086
1087fn concise(value: &str, limit: usize) -> String {
1088    let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
1089    let bounded = normalized.chars().take(limit).collect::<String>();
1090    if bounded.is_empty() {
1091        "Audio transcription failed without an error message.".into()
1092    } else {
1093        bounded
1094    }
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099    use super::*;
1100
1101    fn database() -> Connection {
1102        let connection = Connection::open_in_memory().unwrap();
1103        apply_migrations(&connection).unwrap();
1104        connection
1105    }
1106
1107    fn insert_recording(connection: &Connection, id: Uuid, status: &str) {
1108        connection
1109            .execute(
1110                "INSERT INTO audio_recordings(
1111                id,sha256,original_filename,content_type,size_bytes,source_created_at,
1112                received_at,updated_at,original_relative_path,status,gemini_model,
1113                reconciliation_model,reconciliation_reasoning
1114             ) VALUES(?1,?2,'note.wav','audio/wav',4,?3,?3,?3,?4,?5,?6,?7,?8)",
1115                params![
1116                    id.to_string(),
1117                    format!("{:064x}", 1),
1118                    "2026-01-01T00:00:00Z",
1119                    format!("originals/{id}.wav"),
1120                    status,
1121                    TRANSCRIPTION_MODEL,
1122                    RECONCILIATION_MODEL,
1123                    RECONCILIATION_REASONING,
1124                ],
1125            )
1126            .unwrap();
1127    }
1128
1129    fn sample_piece() -> ChunkTranscript {
1130        ChunkTranscript {
1131            plan: ChunkPlan {
1132                index: 0,
1133                total: 2,
1134                start_ms: 0,
1135                end_ms: 120_000,
1136            },
1137            transcript: r#"{"utterances":[{"original_text":"hello"}]}"#.into(),
1138        }
1139    }
1140
1141    #[test]
1142    fn fresh_schema_contains_recordings_and_durable_pieces() {
1143        let connection = database();
1144        let tables = connection
1145            .prepare(
1146                "SELECT name FROM sqlite_schema
1147                 WHERE type='table' AND name NOT LIKE 'sqlite_%'
1148                 ORDER BY name",
1149            )
1150            .unwrap()
1151            .query_map([], |row| row.get::<_, String>(0))
1152            .unwrap()
1153            .collect::<Result<Vec<_>, _>>()
1154            .unwrap();
1155        let version: i64 = connection
1156            .query_row("PRAGMA user_version", [], |row| row.get(0))
1157            .unwrap();
1158        assert_eq!(tables, vec!["audio_recordings", "audio_transcript_pieces"]);
1159        assert_eq!(version, LATEST_SCHEMA_VERSION);
1160    }
1161
1162    #[test]
1163    fn version_five_databases_upgrade_without_losing_legacy_queue_data() {
1164        let connection = Connection::open_in_memory().unwrap();
1165        connection.execute_batch(INITIAL_MIGRATION).unwrap();
1166        connection
1167            .execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)
1168            .unwrap();
1169        connection
1170            .execute_batch(TRANSCRIPTION_STATUS_MIGRATION)
1171            .unwrap();
1172        connection
1173            .execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)
1174            .unwrap();
1175        connection
1176            .execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)
1177            .unwrap();
1178        let id = Uuid::new_v4();
1179        insert_recording(&connection, id, "failed");
1180
1181        apply_migrations(&connection).unwrap();
1182
1183        let version: i64 = connection
1184            .query_row("PRAGMA user_version", [], |row| row.get(0))
1185            .unwrap();
1186        let retryable: i64 = connection
1187            .query_row(
1188                "SELECT failure_retryable FROM audio_recordings WHERE id=?1",
1189                [id.to_string()],
1190                |row| row.get(0),
1191            )
1192            .unwrap();
1193        let legacy_queue_exists: i64 = connection
1194            .query_row(
1195                "SELECT EXISTS(
1196                    SELECT 1 FROM sqlite_schema
1197                    WHERE type='table' AND name='audio_ingress_pieces'
1198                 )",
1199                [],
1200                |row| row.get(0),
1201            )
1202            .unwrap();
1203        assert_eq!(version, LATEST_SCHEMA_VERSION);
1204        assert_eq!(retryable, 1);
1205        assert_eq!(legacy_queue_exists, 1);
1206    }
1207
1208    #[test]
1209    fn version_seven_piece_rows_migrate_without_loss() {
1210        let connection = Connection::open_in_memory().unwrap();
1211        connection.execute_batch(INITIAL_MIGRATION).unwrap();
1212        connection
1213            .execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)
1214            .unwrap();
1215        connection
1216            .execute_batch(TRANSCRIPTION_STATUS_MIGRATION)
1217            .unwrap();
1218        connection
1219            .execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)
1220            .unwrap();
1221        connection
1222            .execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)
1223            .unwrap();
1224        connection
1225            .execute_batch(STANDALONE_LIBRARY_MIGRATION)
1226            .unwrap();
1227        connection
1228            .execute_batch(DURABLE_TRANSCRIPT_PIECES_MIGRATION)
1229            .unwrap();
1230        let id = Uuid::new_v4();
1231        insert_recording(&connection, id, "transcribing");
1232        connection
1233            .execute(
1234                "INSERT INTO audio_transcript_pieces(
1235                    recording_id,attempt,piece_index,piece_count,audio_start_ms,
1236                    audio_end_ms,transcript_json,created_at
1237                 ) VALUES(?1,1,0,2,0,120000,?2,?3)",
1238                params![
1239                    id.to_string(),
1240                    r#"{"utterances":[{"original_text":"hello"}]}"#,
1241                    "2026-01-01T00:00:00Z",
1242                ],
1243            )
1244            .unwrap();
1245
1246        apply_migrations(&connection).unwrap();
1247
1248        let migrated: (String, String, String) = connection
1249            .query_row(
1250                "SELECT attempt_id,cache_revision,transcript_json
1251                 FROM audio_transcript_pieces
1252                 WHERE recording_id=?1 AND piece_index=0",
1253                [id.to_string()],
1254                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1255            )
1256            .unwrap();
1257        assert_eq!(migrated.0, "version-7-attempt-1");
1258        assert_eq!(migrated.1, PIECE_CACHE_REVISION);
1259        assert!(migrated.2.contains("hello"));
1260    }
1261
1262    #[test]
1263    fn status_exposes_one_complete_transcript() {
1264        let connection = database();
1265        let id = Uuid::new_v4();
1266        insert_recording(&connection, id, "ready_for_ingress");
1267        connection
1268            .execute(
1269                "UPDATE audio_recordings SET final_transcript='hello' WHERE id=?1",
1270                [id.to_string()],
1271            )
1272            .unwrap();
1273        let status = connection
1274            .query_row(
1275                "SELECT id,sha256,original_filename,size_bytes,source_created_at,received_at,
1276                        status,gemini_model,reconciliation_model,reconciliation_reasoning,
1277                        attempt_count,last_error,failure_retryable,transcription_status_json,
1278                        final_transcript
1279                 FROM audio_recordings WHERE id=?1",
1280                [id.to_string()],
1281                row_recording_status,
1282            )
1283            .unwrap();
1284        assert!(matches!(
1285            status.state,
1286            RecordingState::Complete { ref transcript } if transcript == "hello"
1287        ));
1288    }
1289
1290    #[test]
1291    fn transcript_pieces_are_durable_and_attempt_scoped() {
1292        let connection = database();
1293        let id = Uuid::new_v4();
1294        insert_recording(&connection, id, "transcribing");
1295        let db = Mutex::new(connection);
1296        let piece = sample_piece();
1297        persist_transcript_piece(&db, id, "attempt-a", &piece).unwrap();
1298        persist_transcript_piece(&db, id, "attempt-b", &piece).unwrap();
1299
1300        let db = db.lock().unwrap();
1301        let rows: i64 = db
1302            .query_row(
1303                "SELECT COUNT(*) FROM audio_transcript_pieces WHERE recording_id=?1",
1304                [id.to_string()],
1305                |row| row.get(0),
1306            )
1307            .unwrap();
1308        let stored: String = db
1309            .query_row(
1310                "SELECT transcript_json FROM audio_transcript_pieces
1311                 WHERE recording_id=?1 AND attempt_id='attempt-a' AND piece_index=0",
1312                [id.to_string()],
1313                |row| row.get(0),
1314            )
1315            .unwrap();
1316        assert_eq!(rows, 2);
1317        assert_eq!(stored, piece.transcript);
1318    }
1319
1320    #[test]
1321    fn cache_reuses_only_exact_matching_piece_plans() {
1322        let connection = database();
1323        let id = Uuid::new_v4();
1324        insert_recording(&connection, id, "transcribing");
1325        let db = Mutex::new(connection);
1326        let piece = sample_piece();
1327        persist_transcript_piece(&db, id, "attempt-a", &piece).unwrap();
1328
1329        let exact = load_cached_transcript_piece(&db, id, piece.plan).unwrap();
1330        let changed = load_cached_transcript_piece(
1331            &db,
1332            id,
1333            ChunkPlan {
1334                end_ms: piece.plan.end_ms + 1,
1335                ..piece.plan
1336            },
1337        )
1338        .unwrap();
1339
1340        assert_eq!(exact.as_deref(), Some(piece.transcript.as_str()));
1341        assert_eq!(changed, None);
1342    }
1343
1344    #[test]
1345    fn future_schema_versions_are_rejected() {
1346        let connection = Connection::open_in_memory().unwrap();
1347        connection
1348            .execute_batch("PRAGMA user_version = 9;")
1349            .unwrap();
1350        let error = apply_migrations(&connection).unwrap_err().to_string();
1351        assert!(error.contains("newer than supported"));
1352    }
1353
1354    #[test]
1355    fn interrupted_attempts_consume_the_fixed_budget() {
1356        let connection = database();
1357        let id = Uuid::new_v4();
1358        insert_recording(&connection, id, "transcribing");
1359        connection
1360            .execute(
1361                "UPDATE audio_recordings SET attempt_count=5 WHERE id=?1",
1362                [id.to_string()],
1363            )
1364            .unwrap();
1365        recover_interrupted_attempts(&connection).unwrap();
1366        let state: String = connection
1367            .query_row(
1368                "SELECT status FROM audio_recordings WHERE id=?1",
1369                [id.to_string()],
1370                |row| row.get(0),
1371            )
1372            .unwrap();
1373        assert_eq!(state, "failed");
1374    }
1375
1376    #[test]
1377    fn filenames_cannot_escape_the_persistence_root() {
1378        assert_eq!(safe_filename(Some("../../secret.wav")), "secret.wav");
1379        assert_eq!(safe_filename(Some("meeting note.wav")), "meeting_note.wav");
1380        assert_eq!(safe_filename(None), "audio.wav");
1381    }
1382}