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 self.recording_history_inner(recording_id, false).await
213 }
214
215 pub async fn recording_history_with_current_speaker_candidates(
221 &self,
222 recording_id: Uuid,
223 ) -> Result<RecordingHistory, Error> {
224 self.recording_history_inner(recording_id, true).await
225 }
226
227 async fn recording_history_inner(
228 &self,
229 recording_id: Uuid,
230 refresh_speaker_candidates: bool,
231 ) -> Result<RecordingHistory, Error> {
232 let (recordings, projections) = self.prepared_recordings().await?;
233 let (recording_status, projection) = recordings
234 .into_iter()
235 .zip(projections)
236 .find(|(recording, _)| {
237 recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
238 })
239 .ok_or_else(Error::not_found)?;
240 let final_transcript = match &recording_status.state {
241 RecordingState::Complete { transcript } => Some(transcript.clone()),
242 _ => None,
243 };
244 let correction_packet = if refresh_speaker_candidates
245 && recording_status
246 .correction_packet
247 .as_ref()
248 .is_some_and(|packet| packet.chunks.iter().any(|chunk| !chunk.signed_off))
249 {
250 let audio = self.audio.clone();
251 Some(run_blocking_audio(move || audio.speaker_review_packet(recording_id)).await?)
252 } else {
253 recording_status.correction_packet.clone()
254 };
255 let view = kcode_audio_session_view::render(recording_status, projection);
256 Ok(RecordingHistory {
257 recording: view.recording,
258 final_transcript,
259 correction_packet,
260 pieces: view.pieces,
261 })
262 }
263
264 pub async fn speaker_review_audio(
266 &self,
267 recording_id: Uuid,
268 chunk_index: usize,
269 ) -> Result<SpeakerReviewAudio, Error> {
270 let owned = self
271 .audio
272 .status()
273 .map_err(audio_error)?
274 .recordings
275 .into_iter()
276 .any(|recording| {
277 recording.id == recording_id && recording_belongs_to(&recording, &self.user_id)
278 });
279 if !owned {
280 return Err(Error::not_found());
281 }
282 let audio = self.audio.clone();
283 tokio::task::spawn_blocking(move || audio.speaker_review_audio(recording_id, chunk_index))
284 .await
285 .map_err(Error::internal)?
286 .map_err(audio_error)
287 }
288
289 pub fn known_speakers(&self) -> Result<Vec<String>, Error> {
291 self.audio.known_speakers().map_err(audio_error)
292 }
293
294 pub fn retry_recording(&self, recording_id: Uuid) -> Result<(), Error> {
295 let owned = self
296 .audio
297 .status()
298 .map_err(audio_error)?
299 .recordings
300 .into_iter()
301 .any(|recording| {
302 recording.id == recording_id && recording_belongs_to(&recording, &self.user_id)
303 });
304 if !owned {
305 return Err(Error::not_found());
306 }
307 self.audio.retry(recording_id).map_err(audio_error)
308 }
309
310 pub async fn confirm_speakers(
311 &self,
312 confirmation: ChunkConfirmation,
313 ) -> Result<CorrectionPacket, Error> {
314 let recording_id = confirmation.recording_id;
315 let (recordings, _) = self.prepared_recordings().await?;
316 let recording = recordings
317 .into_iter()
318 .find(|recording| {
319 recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
320 })
321 .ok_or_else(Error::not_found)?;
322
323 if let Some(packet) = recording
324 .correction_packet
325 .as_ref()
326 .filter(|packet| confirmation_matches(packet, &confirmation))
327 {
328 self.handoff
329 .synchronize(std::slice::from_ref(&recording))
330 .await
331 .map_err(handoff_error)?;
332 return Ok(packet.clone());
333 }
334 if self
335 .handoff
336 .has_ingress(recording_id)
337 .await
338 .map_err(handoff_error)?
339 {
340 return Err(Error::conflict(
341 "speaker labels are already bound to accepted transcript ingress",
342 ));
343 }
344
345 let audio = self.audio.clone();
346 let packet = run_blocking_audio(move || audio.confirm_speakers(confirmation)).await?;
347 let recording = self
348 .audio
349 .status()
350 .map_err(audio_error)?
351 .recordings
352 .into_iter()
353 .find(|recording| {
354 recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
355 })
356 .ok_or_else(Error::not_found)?;
357 self.handoff
358 .synchronize(std::slice::from_ref(&recording))
359 .await
360 .map_err(handoff_error)?;
361 Ok(packet)
362 }
363
364 pub async fn retry_ingress(&self, input: RetryIngress) -> Result<SessionRecord, Error> {
365 let recordings = self.audio.status().map_err(audio_error)?.recordings;
366 self.handoff
367 .retry(
368 &recordings,
369 &input.piece_id,
370 input.expected_version,
371 input.state,
372 )
373 .await
374 .map_err(handoff_error)
375 }
376
377 pub async fn synchronize_completed_transcripts(&self) -> Result<(), Error> {
378 let mut recordings = self.owned_recordings()?;
379 if legacy_review::has_candidates(&recordings) {
380 let projections = self.project_recordings(&recordings).await?;
381 if self
382 .resolve_legacy_reviews(&recordings, &projections)
383 .await?
384 {
385 recordings = self.owned_recordings()?;
386 }
387 }
388 self.handoff
389 .synchronize(&recordings)
390 .await
391 .map_err(handoff_error)
392 }
393}
394
395fn recording_belongs_to(recording: &RecordingStatus, user_id: &str) -> bool {
396 recording.user_id == user_id
397}
398
399fn validate_submission_owner(recording: &RecordingStatus, user_id: &str) -> Result<(), Error> {
400 if recording_belongs_to(recording, user_id) {
401 Ok(())
402 } else {
403 Err(Error::conflict(
404 "identical audio is already attributed to another user",
405 ))
406 }
407}
408
409fn audio_error(error: kcode_audio_ingress::Error) -> Error {
410 match error.kind() {
411 AudioErrorKind::InvalidInput => Error::new(ErrorKind::InvalidInput, error.to_string()),
412 AudioErrorKind::NotFound => Error::new(ErrorKind::NotFound, error.to_string()),
413 AudioErrorKind::Conflict => Error::new(ErrorKind::Conflict, error.to_string()),
414 AudioErrorKind::Internal => Error::internal(error),
415 }
416}
417
418async fn run_blocking_audio<T, Operation>(operation: Operation) -> Result<T, Error>
419where
420 T: Send + 'static,
421 Operation: FnOnce() -> Result<T, kcode_audio_ingress::Error> + Send + 'static,
422{
423 tokio::task::spawn_blocking(operation)
424 .await
425 .map_err(Error::internal)?
426 .map_err(audio_error)
427}
428
429fn handoff_error(error: HandoffError) -> Error {
430 match error {
431 HandoffError::InvalidInput(message) => Error::new(ErrorKind::InvalidInput, message),
432 HandoffError::NotFound(message) => Error::new(ErrorKind::NotFound, message),
433 HandoffError::Conflict(message) => Error::new(ErrorKind::Conflict, message),
434 HandoffError::Internal(message) => Error::new(ErrorKind::Internal, message),
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 fn completed_recording_for_user(
443 user_id: impl Into<String>,
444 transcript: impl Into<String>,
445 ) -> RecordingStatus {
446 let now = Utc::now();
447 RecordingStatus {
448 id: Uuid::new_v4(),
449 user_id: user_id.into(),
450 sha256: "0".repeat(64),
451 original_filename: "meeting.final.WAV".into(),
452 size_bytes: 42,
453 recorded_at: now,
454 received_at: now,
455 transcription_model: "transcription-model".into(),
456 reconciliation_model: "reconciliation-model".into(),
457 reconciliation_reasoning: "xhigh".into(),
458 state: RecordingState::Complete {
459 transcript: transcript.into(),
460 },
461 correction_packet: None,
462 }
463 }
464
465 #[test]
466 fn recording_filter_is_exactly_scoped_to_the_configured_user() {
467 let own = completed_recording_for_user("own-user", "Own transcript");
468 let foreign = completed_recording_for_user("foreign-user", "Foreign transcript");
469 let visible = [own.clone(), foreign]
470 .into_iter()
471 .filter(|recording| recording_belongs_to(recording, "own-user"))
472 .collect::<Vec<_>>();
473 assert_eq!(visible.len(), 1);
474 assert_eq!(visible[0].id, own.id);
475 }
476
477 #[test]
478 fn cross_user_sha_deduplication_fails_closed() {
479 let mut own = completed_recording_for_user("own-user", "Own transcript");
480 own.sha256 = "a".repeat(64);
481 let mut foreign = completed_recording_for_user("foreign-user", "Foreign transcript");
482 foreign.sha256 = own.sha256.clone();
483
484 assert!(validate_submission_owner(&own, "own-user").is_ok());
485 let error = validate_submission_owner(&foreign, "own-user").unwrap_err();
486 assert_eq!(error.kind(), ErrorKind::Conflict);
487 assert_eq!(
488 error.message(),
489 "identical audio is already attributed to another user"
490 );
491 }
492
493 #[test]
494 fn handoff_errors_preserve_category_and_message() {
495 let cases = [
496 (
497 HandoffError::InvalidInput("invalid".into()),
498 ErrorKind::InvalidInput,
499 "invalid",
500 ),
501 (
502 HandoffError::NotFound("missing".into()),
503 ErrorKind::NotFound,
504 "missing",
505 ),
506 (
507 HandoffError::Conflict("conflict".into()),
508 ErrorKind::Conflict,
509 "conflict",
510 ),
511 (
512 HandoffError::Internal("raw storage message".into()),
513 ErrorKind::Internal,
514 "raw storage message",
515 ),
516 ];
517
518 for (source, kind, message) in cases {
519 let error = handoff_error(source);
520 assert_eq!(error.kind(), kind);
521 assert_eq!(error.message(), message);
522 }
523 }
524
525 #[test]
526 fn blocking_audio_work_does_not_occupy_the_async_runtime_thread() {
527 use std::{
528 sync::{
529 Arc,
530 atomic::{AtomicBool, Ordering},
531 },
532 time::{Duration, Instant},
533 };
534
535 let runtime = tokio::runtime::Builder::new_current_thread()
536 .enable_time()
537 .build()
538 .unwrap();
539 runtime.block_on(async {
540 let started = Arc::new(AtomicBool::new(false));
541 let worker_started = Arc::clone(&started);
542 let work = tokio::spawn(run_blocking_audio(move || {
543 worker_started.store(true, Ordering::Release);
544 std::thread::sleep(Duration::from_millis(100));
545 Ok::<_, kcode_audio_ingress::Error>(())
546 }));
547 while !started.load(Ordering::Acquire) {
548 tokio::task::yield_now().await;
549 }
550 let before = Instant::now();
551 tokio::time::sleep(Duration::from_millis(10)).await;
552 assert!(before.elapsed() < Duration::from_millis(75));
553 work.await.unwrap().unwrap();
554 });
555 }
556}