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#[derive(Clone, Debug)]
19pub struct Config {
20 pub user_id: String,
22 pub effective_context_tokens: u64,
24}
25
26#[derive(Clone, Debug)]
28pub struct RecordingInput {
29 pub bytes: Vec<u8>,
31 pub recorded_at: DateTime<Utc>,
33 pub original_filename: Option<String>,
35}
36
37#[derive(Clone, Debug)]
39pub struct RecordingSubmission {
40 pub recording: Recording,
42 pub deduplicated: bool,
44}
45
46#[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#[derive(Clone, Debug)]
57pub struct RetryIngress {
58 pub piece_id: String,
59 pub expected_version: i64,
60 pub state: Option<Value>,
63}
64
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub enum ErrorKind {
68 InvalidInput,
69 NotFound,
70 Conflict,
71 Internal,
72}
73
74#[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#[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 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 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}