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