Skip to main content

kcode_audio_session_ingress/
lib.rs

1#![forbid(unsafe_code)]
2
3use chrono::{DateTime, Utc};
4use kcode_audio_history_handoff::{Error as HandoffError, Handoff};
5pub use kcode_audio_ingress::SpeakerReviewAudio;
6use kcode_audio_ingress::{
7    AudioIngress, AudioInput, ChunkConfirmation, CorrectionPacket, ErrorKind as AudioErrorKind,
8    RecordingState, RecordingStatus, confirmation_matches,
9};
10pub use kcode_audio_session_view::{IngressPiece, Recording, SpeakerReview};
11use kcode_session_history::{SessionHistory, SessionRecord};
12use serde_json::Value;
13use uuid::Uuid;
14
15mod legacy_review;
16
17/// Application policy needed to coordinate audio with Session History.
18#[derive(Clone, Debug)]
19pub struct Config {
20    /// Stable application user identifier attributed to accepted recordings.
21    pub user_id: String,
22    /// Effective context window available to an audio-ingress session.
23    pub effective_context_tokens: u64,
24}
25
26/// Admitted recording bytes and source metadata.
27#[derive(Clone, Debug)]
28pub struct RecordingInput {
29    /// Complete WAV bytes already accepted by the transport.
30    pub bytes: Vec<u8>,
31    /// Instant at which the original recording began.
32    pub recorded_at: DateTime<Utc>,
33    /// Original leaf filename, when known.
34    pub original_filename: Option<String>,
35}
36
37/// Result of durably submitting one recording.
38#[derive(Clone, Debug)]
39pub struct RecordingSubmission {
40    /// Current combined state after submission.
41    pub recording: Recording,
42    /// Whether AudioIngress already knew the same bytes.
43    pub deduplicated: bool,
44}
45
46/// Detailed state for one recording.
47#[derive(Clone, Debug)]
48pub struct RecordingHistory {
49    pub recording: Recording,
50    pub final_transcript: Option<String>,
51    pub correction_packet: Option<CorrectionPacket>,
52    pub pieces: Vec<IngressPiece>,
53}
54
55/// Request to retry one transcript piece's memory ingress.
56#[derive(Clone, Debug)]
57pub struct RetryIngress {
58    pub piece_id: String,
59    pub expected_version: i64,
60    /// Optional state retained for 0.2 wire compatibility. When supplied, it
61    /// must exactly equal the current retained Session History state.
62    pub state: Option<Value>,
63}
64
65/// Stable coordinator error category for transport mapping.
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub enum ErrorKind {
68    InvalidInput,
69    NotFound,
70    Conflict,
71    Internal,
72}
73
74/// Error returned by the audio/session-ingress coordinator.
75#[derive(Debug)]
76pub struct Error {
77    kind: ErrorKind,
78    message: String,
79}
80
81impl Error {
82    fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
83        Self {
84            kind,
85            message: message.into(),
86        }
87    }
88
89    fn invalid(message: impl Into<String>) -> Self {
90        Self::new(ErrorKind::InvalidInput, message)
91    }
92
93    fn conflict(message: impl Into<String>) -> Self {
94        Self::new(ErrorKind::Conflict, message)
95    }
96
97    fn not_found() -> Self {
98        Self::new(
99            ErrorKind::NotFound,
100            "Audio recording or transcript piece not found.",
101        )
102    }
103
104    fn internal(error: impl std::fmt::Display) -> Self {
105        tracing::warn!(%error, "Audio session ingress operation failed");
106        Self::new(
107            ErrorKind::Internal,
108            "An unexpected audio session ingress error occurred.",
109        )
110    }
111
112    pub fn kind(&self) -> ErrorKind {
113        self.kind
114    }
115
116    pub fn message(&self) -> &str {
117        &self.message
118    }
119}
120
121impl std::fmt::Display for Error {
122    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        formatter.write_str(&self.message)
124    }
125}
126
127impl std::error::Error for Error {}
128
129/// Cloneable typed coordinator over AudioIngress and Session History.
130#[derive(Clone)]
131pub struct Coordinator {
132    audio: AudioIngress,
133    handoff: Handoff,
134    user_id: String,
135}
136
137impl Coordinator {
138    pub fn new(
139        audio: AudioIngress,
140        history: SessionHistory,
141        config: Config,
142    ) -> Result<Self, Error> {
143        let handoff = Handoff::new(
144            history,
145            config.user_id.clone(),
146            config.effective_context_tokens,
147        )
148        .map_err(handoff_error)?;
149        Ok(Self {
150            audio,
151            handoff,
152            user_id: config.user_id,
153        })
154    }
155
156    pub fn health(&self) -> Result<(), Error> {
157        self.audio.status().map_err(audio_error)?;
158        Ok(())
159    }
160
161    pub async fn submit(&self, input: RecordingInput) -> Result<RecordingSubmission, Error> {
162        let submission = self
163            .audio
164            .submit(AudioInput {
165                user_id: self.user_id.clone(),
166                bytes: input.bytes,
167                recorded_at: input.recorded_at,
168                original_filename: input.original_filename,
169            })
170            .await
171            .map_err(audio_error)?;
172        let (recordings, projections) = self.prepared_recordings().await?;
173        let (recording_status, projection) = recordings
174            .into_iter()
175            .zip(projections)
176            .find(|(recording, _)| recording.id == submission.recording_id)
177            .ok_or_else(Error::not_found)?;
178        validate_submission_owner(&recording_status, &self.user_id)?;
179        let view = kcode_audio_session_view::render(recording_status, projection);
180        Ok(RecordingSubmission {
181            recording: view.recording,
182            deduplicated: submission.deduplicated,
183        })
184    }
185
186    pub async fn recordings(&self) -> Result<Vec<Recording>, Error> {
187        let (recordings, projections) = self.prepared_recordings().await?;
188        Ok(recordings
189            .into_iter()
190            .zip(projections)
191            .map(|(recording, projection)| {
192                kcode_audio_session_view::render(recording, projection).recording
193            })
194            .collect())
195    }
196
197    pub async fn recording_by_sha256(&self, sha256: &str) -> Result<Recording, Error> {
198        if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
199            return Err(Error::invalid(
200                "audio SHA-256 must contain exactly 64 hexadecimal characters",
201            ));
202        }
203        let normalized = sha256.to_ascii_lowercase();
204        self.recordings()
205            .await?
206            .into_iter()
207            .find(|recording| recording.sha256 == normalized)
208            .ok_or_else(Error::not_found)
209    }
210
211    pub async fn recording_history(&self, recording_id: Uuid) -> Result<RecordingHistory, Error> {
212        let (recordings, projections) = self.prepared_recordings().await?;
213        let (recording_status, projection) = recordings
214            .into_iter()
215            .zip(projections)
216            .find(|(recording, _)| {
217                recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
218            })
219            .ok_or_else(Error::not_found)?;
220        let final_transcript = match &recording_status.state {
221            RecordingState::Complete { transcript } => Some(transcript.clone()),
222            _ => None,
223        };
224        let correction_packet = recording_status.correction_packet.clone();
225        let view = kcode_audio_session_view::render(recording_status, projection);
226        Ok(RecordingHistory {
227            recording: view.recording,
228            final_transcript,
229            correction_packet,
230            pieces: view.pieces,
231        })
232    }
233
234    /// Returns one authorized correction-packet chunk as an exact WAV interval.
235    pub async fn speaker_review_audio(
236        &self,
237        recording_id: Uuid,
238        chunk_index: usize,
239    ) -> Result<SpeakerReviewAudio, Error> {
240        let owned = self
241            .audio
242            .status()
243            .map_err(audio_error)?
244            .recordings
245            .into_iter()
246            .any(|recording| {
247                recording.id == recording_id && recording_belongs_to(&recording, &self.user_id)
248            });
249        if !owned {
250            return Err(Error::not_found());
251        }
252        let audio = self.audio.clone();
253        tokio::task::spawn_blocking(move || audio.speaker_review_audio(recording_id, chunk_index))
254            .await
255            .map_err(Error::internal)?
256            .map_err(audio_error)
257    }
258
259    /// Returns the known-speaker dropdown choices for this coordinator.
260    pub fn known_speakers(&self) -> Result<Vec<String>, Error> {
261        self.audio.known_speakers().map_err(audio_error)
262    }
263
264    pub fn retry_recording(&self, recording_id: Uuid) -> Result<(), Error> {
265        let owned = self
266            .audio
267            .status()
268            .map_err(audio_error)?
269            .recordings
270            .into_iter()
271            .any(|recording| {
272                recording.id == recording_id && recording_belongs_to(&recording, &self.user_id)
273            });
274        if !owned {
275            return Err(Error::not_found());
276        }
277        self.audio.retry(recording_id).map_err(audio_error)
278    }
279
280    pub async fn confirm_speakers(
281        &self,
282        confirmation: ChunkConfirmation,
283    ) -> Result<CorrectionPacket, Error> {
284        let recording_id = confirmation.recording_id;
285        let (recordings, _) = self.prepared_recordings().await?;
286        let recording = recordings
287            .into_iter()
288            .find(|recording| {
289                recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
290            })
291            .ok_or_else(Error::not_found)?;
292
293        if let Some(packet) = recording
294            .correction_packet
295            .as_ref()
296            .filter(|packet| confirmation_matches(packet, &confirmation))
297        {
298            self.handoff
299                .synchronize(std::slice::from_ref(&recording))
300                .await
301                .map_err(handoff_error)?;
302            return Ok(packet.clone());
303        }
304        if self
305            .handoff
306            .has_ingress(recording_id)
307            .await
308            .map_err(handoff_error)?
309        {
310            return Err(Error::conflict(
311                "speaker labels are already bound to accepted transcript ingress",
312            ));
313        }
314
315        let audio = self.audio.clone();
316        let packet = run_blocking_audio(move || audio.confirm_speakers(confirmation)).await?;
317        let recording = self
318            .audio
319            .status()
320            .map_err(audio_error)?
321            .recordings
322            .into_iter()
323            .find(|recording| {
324                recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
325            })
326            .ok_or_else(Error::not_found)?;
327        self.handoff
328            .synchronize(std::slice::from_ref(&recording))
329            .await
330            .map_err(handoff_error)?;
331        Ok(packet)
332    }
333
334    pub async fn retry_ingress(&self, input: RetryIngress) -> Result<SessionRecord, Error> {
335        let recordings = self.audio.status().map_err(audio_error)?.recordings;
336        self.handoff
337            .retry(
338                &recordings,
339                &input.piece_id,
340                input.expected_version,
341                input.state,
342            )
343            .await
344            .map_err(handoff_error)
345    }
346
347    pub async fn synchronize_completed_transcripts(&self) -> Result<(), Error> {
348        let mut recordings = self.owned_recordings()?;
349        if legacy_review::has_candidates(&recordings) {
350            let projections = self.project_recordings(&recordings).await?;
351            if self
352                .resolve_legacy_reviews(&recordings, &projections)
353                .await?
354            {
355                recordings = self.owned_recordings()?;
356            }
357        }
358        self.handoff
359            .synchronize(&recordings)
360            .await
361            .map_err(handoff_error)
362    }
363}
364
365fn recording_belongs_to(recording: &RecordingStatus, user_id: &str) -> bool {
366    recording.user_id == user_id
367}
368
369fn validate_submission_owner(recording: &RecordingStatus, user_id: &str) -> Result<(), Error> {
370    if recording_belongs_to(recording, user_id) {
371        Ok(())
372    } else {
373        Err(Error::conflict(
374            "identical audio is already attributed to another user",
375        ))
376    }
377}
378
379fn audio_error(error: kcode_audio_ingress::Error) -> Error {
380    match error.kind() {
381        AudioErrorKind::InvalidInput => Error::new(ErrorKind::InvalidInput, error.to_string()),
382        AudioErrorKind::NotFound => Error::new(ErrorKind::NotFound, error.to_string()),
383        AudioErrorKind::Conflict => Error::new(ErrorKind::Conflict, error.to_string()),
384        AudioErrorKind::Internal => Error::internal(error),
385    }
386}
387
388async fn run_blocking_audio<T, Operation>(operation: Operation) -> Result<T, Error>
389where
390    T: Send + 'static,
391    Operation: FnOnce() -> Result<T, kcode_audio_ingress::Error> + Send + 'static,
392{
393    tokio::task::spawn_blocking(operation)
394        .await
395        .map_err(Error::internal)?
396        .map_err(audio_error)
397}
398
399fn handoff_error(error: HandoffError) -> Error {
400    match error {
401        HandoffError::InvalidInput(message) => Error::new(ErrorKind::InvalidInput, message),
402        HandoffError::NotFound(message) => Error::new(ErrorKind::NotFound, message),
403        HandoffError::Conflict(message) => Error::new(ErrorKind::Conflict, message),
404        HandoffError::Internal(message) => Error::new(ErrorKind::Internal, message),
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    fn completed_recording_for_user(
413        user_id: impl Into<String>,
414        transcript: impl Into<String>,
415    ) -> RecordingStatus {
416        let now = Utc::now();
417        RecordingStatus {
418            id: Uuid::new_v4(),
419            user_id: user_id.into(),
420            sha256: "0".repeat(64),
421            original_filename: "meeting.final.WAV".into(),
422            size_bytes: 42,
423            recorded_at: now,
424            received_at: now,
425            transcription_model: "transcription-model".into(),
426            reconciliation_model: "reconciliation-model".into(),
427            reconciliation_reasoning: "xhigh".into(),
428            state: RecordingState::Complete {
429                transcript: transcript.into(),
430            },
431            correction_packet: None,
432        }
433    }
434
435    #[test]
436    fn recording_filter_is_exactly_scoped_to_the_configured_user() {
437        let own = completed_recording_for_user("own-user", "Own transcript");
438        let foreign = completed_recording_for_user("foreign-user", "Foreign transcript");
439        let visible = [own.clone(), foreign]
440            .into_iter()
441            .filter(|recording| recording_belongs_to(recording, "own-user"))
442            .collect::<Vec<_>>();
443        assert_eq!(visible.len(), 1);
444        assert_eq!(visible[0].id, own.id);
445    }
446
447    #[test]
448    fn cross_user_sha_deduplication_fails_closed() {
449        let mut own = completed_recording_for_user("own-user", "Own transcript");
450        own.sha256 = "a".repeat(64);
451        let mut foreign = completed_recording_for_user("foreign-user", "Foreign transcript");
452        foreign.sha256 = own.sha256.clone();
453
454        assert!(validate_submission_owner(&own, "own-user").is_ok());
455        let error = validate_submission_owner(&foreign, "own-user").unwrap_err();
456        assert_eq!(error.kind(), ErrorKind::Conflict);
457        assert_eq!(
458            error.message(),
459            "identical audio is already attributed to another user"
460        );
461    }
462
463    #[test]
464    fn handoff_errors_preserve_category_and_message() {
465        let cases = [
466            (
467                HandoffError::InvalidInput("invalid".into()),
468                ErrorKind::InvalidInput,
469                "invalid",
470            ),
471            (
472                HandoffError::NotFound("missing".into()),
473                ErrorKind::NotFound,
474                "missing",
475            ),
476            (
477                HandoffError::Conflict("conflict".into()),
478                ErrorKind::Conflict,
479                "conflict",
480            ),
481            (
482                HandoffError::Internal("raw storage message".into()),
483                ErrorKind::Internal,
484                "raw storage message",
485            ),
486        ];
487
488        for (source, kind, message) in cases {
489            let error = handoff_error(source);
490            assert_eq!(error.kind(), kind);
491            assert_eq!(error.message(), message);
492        }
493    }
494
495    #[test]
496    fn blocking_audio_work_does_not_occupy_the_async_runtime_thread() {
497        use std::{
498            sync::{
499                Arc,
500                atomic::{AtomicBool, Ordering},
501            },
502            time::{Duration, Instant},
503        };
504
505        let runtime = tokio::runtime::Builder::new_current_thread()
506            .enable_time()
507            .build()
508            .unwrap();
509        runtime.block_on(async {
510            let started = Arc::new(AtomicBool::new(false));
511            let worker_started = Arc::clone(&started);
512            let work = tokio::spawn(run_blocking_audio(move || {
513                worker_started.store(true, Ordering::Release);
514                std::thread::sleep(Duration::from_millis(100));
515                Ok::<_, kcode_audio_ingress::Error>(())
516            }));
517            while !started.load(Ordering::Acquire) {
518                tokio::task::yield_now().await;
519            }
520            let before = Instant::now();
521            tokio::time::sleep(Duration::from_millis(10)).await;
522            assert!(before.elapsed() < Duration::from_millis(75));
523            work.await.unwrap().unwrap();
524        });
525    }
526}