1#![forbid(unsafe_code)]
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use kcode_audio_history_handoff::{
7 Error as HandoffError, Handoff, PieceProjection, RecordingProjection,
8};
9use kcode_audio_ingress::{
10 AudioIngress, AudioInput, ConfirmationState, CorrectionPacket, ErrorKind as AudioErrorKind,
11 RecordingConfirmation, RecordingState, RecordingStatus,
12};
13use kcode_session_history::{SessionHistory, SessionRecord};
14use serde_json::Value;
15use uuid::Uuid;
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 Recording {
49 pub id: Uuid,
50 pub sha256: String,
51 pub original_filename: String,
52 pub content_type: &'static str,
53 pub size_bytes: u64,
54 pub source_created_at: String,
55 pub received_at: String,
56 pub updated_at: String,
57 pub status: String,
58 pub transcription_model: String,
59 pub reconciliation_model: String,
60 pub reconciliation_reasoning: String,
61 pub transcription_status: Option<Value>,
62 pub attempt_count: i64,
63 pub next_attempt_at: Option<String>,
64 pub last_error: Option<String>,
65 pub speaker_review: Option<SpeakerReview>,
66 pub transcript_piece_count: usize,
67 pub completed_piece_count: usize,
68}
69
70#[derive(Clone, Debug)]
72pub struct SpeakerReview {
73 pub clean: bool,
74 pub confirmation_state: ConfirmationState,
75 pub observation_count: usize,
76}
77
78#[derive(Clone, Debug)]
80pub struct IngressPiece {
81 pub id: String,
82 pub recording_id: Uuid,
83 pub sha256: String,
84 pub original_filename: String,
85 pub source_created_at: String,
86 pub piece_index: u32,
87 pub piece_count: u32,
88 pub transcript_text: String,
89 pub estimated_tokens: u64,
90 pub phase: String,
91 pub provenance_id: Option<String>,
92 pub state: Value,
93 pub version: i64,
94 pub ingress_failure_count: i64,
95 pub ingress_failures: Value,
96 pub created_at: String,
97 pub updated_at: String,
98}
99
100#[derive(Clone, Debug)]
102pub struct RecordingHistory {
103 pub recording: Recording,
104 pub final_transcript: Option<String>,
105 pub correction_packet: Option<CorrectionPacket>,
106 pub pieces: Vec<IngressPiece>,
107}
108
109#[derive(Clone, Debug)]
111pub struct RetryIngress {
112 pub piece_id: String,
113 pub expected_version: i64,
114 pub state: Option<Value>,
117}
118
119#[derive(Clone, Copy, Debug, Eq, PartialEq)]
121pub enum ErrorKind {
122 InvalidInput,
123 NotFound,
124 Conflict,
125 Internal,
126}
127
128#[derive(Debug)]
130pub struct Error {
131 kind: ErrorKind,
132 message: String,
133}
134
135impl Error {
136 fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
137 Self {
138 kind,
139 message: message.into(),
140 }
141 }
142
143 fn invalid(message: impl Into<String>) -> Self {
144 Self::new(ErrorKind::InvalidInput, message)
145 }
146
147 fn conflict(message: impl Into<String>) -> Self {
148 Self::new(ErrorKind::Conflict, message)
149 }
150
151 fn not_found() -> Self {
152 Self::new(
153 ErrorKind::NotFound,
154 "Audio recording or transcript piece not found.",
155 )
156 }
157
158 fn internal(error: impl std::fmt::Display) -> Self {
159 tracing::warn!(%error, "Audio session ingress operation failed");
160 Self::new(
161 ErrorKind::Internal,
162 "An unexpected audio session ingress error occurred.",
163 )
164 }
165
166 pub fn kind(&self) -> ErrorKind {
167 self.kind
168 }
169
170 pub fn message(&self) -> &str {
171 &self.message
172 }
173}
174
175impl std::fmt::Display for Error {
176 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 formatter.write_str(&self.message)
178 }
179}
180
181impl std::error::Error for Error {}
182
183#[derive(Clone)]
185pub struct Coordinator {
186 audio: AudioIngress,
187 handoff: Handoff,
188 user_id: String,
189}
190
191impl Coordinator {
192 pub fn new(
193 audio: AudioIngress,
194 history: SessionHistory,
195 config: Config,
196 ) -> Result<Self, Error> {
197 let handoff = Handoff::new(
198 history,
199 config.user_id.clone(),
200 config.effective_context_tokens,
201 )
202 .map_err(handoff_error)?;
203 Ok(Self {
204 audio,
205 handoff,
206 user_id: config.user_id,
207 })
208 }
209
210 pub fn health(&self) -> Result<(), Error> {
211 self.audio.status().map_err(audio_error)?;
212 Ok(())
213 }
214
215 pub async fn submit(&self, input: RecordingInput) -> Result<RecordingSubmission, Error> {
216 let submission = self
217 .audio
218 .submit(AudioInput {
219 user_id: self.user_id.clone(),
220 bytes: input.bytes,
221 recorded_at: input.recorded_at,
222 original_filename: input.original_filename,
223 })
224 .await
225 .map_err(audio_error)?;
226 let recording_status = self
227 .audio
228 .status()
229 .map_err(audio_error)?
230 .recordings
231 .into_iter()
232 .find(|recording| recording.id == submission.recording_id)
233 .ok_or_else(Error::not_found)?;
234 validate_submission_owner(&recording_status, &self.user_id)?;
235
236 let projection = self
237 .handoff
238 .project(std::slice::from_ref(&recording_status))
239 .await
240 .map_err(handoff_error)?
241 .into_iter()
242 .next()
243 .ok_or_else(|| Error::internal("handoff omitted recording projection"))?;
244 let projection = IngressProjection::from_handoff(&recording_status, projection);
245 Ok(RecordingSubmission {
246 recording: Recording::from_status(recording_status, &projection),
247 deduplicated: submission.deduplicated,
248 })
249 }
250
251 pub async fn recordings(&self) -> Result<Vec<Recording>, Error> {
252 let recordings = self
253 .audio
254 .status()
255 .map_err(audio_error)?
256 .recordings
257 .into_iter()
258 .filter(|recording| recording_belongs_to(recording, &self.user_id))
259 .collect::<Vec<_>>();
260 let projections = self
261 .handoff
262 .project(&recordings)
263 .await
264 .map_err(handoff_error)?;
265 if recordings.len() != projections.len() {
266 return Err(Error::internal("handoff returned incomplete projections"));
267 }
268 Ok(recordings
269 .into_iter()
270 .zip(projections)
271 .map(|(recording, projection)| {
272 let projection = IngressProjection::from_handoff(&recording, projection);
273 Recording::from_status(recording, &projection)
274 })
275 .collect())
276 }
277
278 pub async fn recording_by_sha256(&self, sha256: &str) -> Result<Recording, Error> {
279 if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
280 return Err(Error::invalid(
281 "audio SHA-256 must contain exactly 64 hexadecimal characters",
282 ));
283 }
284 let normalized = sha256.to_ascii_lowercase();
285 self.recordings()
286 .await?
287 .into_iter()
288 .find(|recording| recording.sha256 == normalized)
289 .ok_or_else(Error::not_found)
290 }
291
292 pub async fn recording_history(&self, recording_id: Uuid) -> Result<RecordingHistory, Error> {
293 let recording_status = self
294 .audio
295 .status()
296 .map_err(audio_error)?
297 .recordings
298 .into_iter()
299 .find(|recording| {
300 recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
301 })
302 .ok_or_else(Error::not_found)?;
303 let projection = self
304 .handoff
305 .project(std::slice::from_ref(&recording_status))
306 .await
307 .map_err(handoff_error)?
308 .into_iter()
309 .next()
310 .ok_or_else(|| Error::internal("handoff omitted recording projection"))?;
311 let projection = IngressProjection::from_handoff(&recording_status, projection);
312 let final_transcript = match &recording_status.state {
313 RecordingState::Complete { transcript } => Some(transcript.clone()),
314 _ => None,
315 };
316 let correction_packet = recording_status.correction_packet.clone();
317 let recording = Recording::from_status(recording_status, &projection);
318 Ok(RecordingHistory {
319 recording,
320 final_transcript,
321 correction_packet,
322 pieces: projection.pieces,
323 })
324 }
325
326 pub fn retry_recording(&self, recording_id: Uuid) -> Result<(), Error> {
327 let owned = self
328 .audio
329 .status()
330 .map_err(audio_error)?
331 .recordings
332 .into_iter()
333 .any(|recording| {
334 recording.id == recording_id && recording_belongs_to(&recording, &self.user_id)
335 });
336 if !owned {
337 return Err(Error::not_found());
338 }
339 self.audio.retry(recording_id).map_err(audio_error)
340 }
341
342 pub async fn confirm_speakers(
343 &self,
344 confirmation: RecordingConfirmation,
345 ) -> Result<CorrectionPacket, Error> {
346 let recording_id = confirmation.recording_id;
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
358 if let Some(packet) = recording
359 .correction_packet
360 .as_ref()
361 .filter(|packet| confirmation_matches(packet, &confirmation))
362 {
363 self.handoff
364 .synchronize(std::slice::from_ref(&recording))
365 .await
366 .map_err(handoff_error)?;
367 return Ok(packet.clone());
368 }
369 if self
370 .handoff
371 .has_ingress(recording_id)
372 .await
373 .map_err(handoff_error)?
374 {
375 return Err(Error::conflict(
376 "speaker labels are already bound to accepted transcript ingress",
377 ));
378 }
379
380 let packet = self
381 .audio
382 .confirm_speakers(confirmation)
383 .map_err(audio_error)?;
384 let recording = self
385 .audio
386 .status()
387 .map_err(audio_error)?
388 .recordings
389 .into_iter()
390 .find(|recording| {
391 recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
392 })
393 .ok_or_else(Error::not_found)?;
394 self.handoff
395 .synchronize(std::slice::from_ref(&recording))
396 .await
397 .map_err(handoff_error)?;
398 Ok(packet)
399 }
400
401 pub async fn retry_ingress(&self, input: RetryIngress) -> Result<SessionRecord, Error> {
402 let recordings = self.audio.status().map_err(audio_error)?.recordings;
403 self.handoff
404 .retry(
405 &recordings,
406 &input.piece_id,
407 input.expected_version,
408 input.state,
409 )
410 .await
411 .map_err(handoff_error)
412 }
413
414 pub async fn synchronize_completed_transcripts(&self) -> Result<(), Error> {
415 let recordings = self.audio.status().map_err(audio_error)?.recordings;
416 self.handoff
417 .synchronize(&recordings)
418 .await
419 .map_err(handoff_error)
420 }
421}
422
423fn recording_belongs_to(recording: &RecordingStatus, user_id: &str) -> bool {
424 recording.user_id == user_id
425}
426
427fn validate_submission_owner(recording: &RecordingStatus, user_id: &str) -> Result<(), Error> {
428 if recording_belongs_to(recording, user_id) {
429 Ok(())
430 } else {
431 Err(Error::conflict(
432 "identical audio is already attributed to another user",
433 ))
434 }
435}
436
437fn confirmation_matches(packet: &CorrectionPacket, confirmation: &RecordingConfirmation) -> bool {
438 if packet.confirmation_state != ConfirmationState::Confirmed {
439 return false;
440 }
441 let existing = packet
442 .chunks
443 .iter()
444 .flat_map(|chunk| &chunk.observations)
445 .filter_map(|observation| {
446 observation
447 .confirmed_full_name
448 .as_deref()
449 .map(|name| (&observation.observation_key, name))
450 })
451 .collect::<HashMap<_, _>>();
452 existing.len() == confirmation.observations.len()
453 && confirmation.observations.iter().all(|observation| {
454 existing.get(&observation.observation_key).copied()
455 == Some(observation.confirmed_full_name.trim())
456 })
457}
458
459#[derive(Debug, Default)]
460struct IngressProjection {
461 pieces: Vec<IngressPiece>,
462}
463
464impl IngressProjection {
465 fn from_handoff(recording: &RecordingStatus, projection: RecordingProjection) -> Self {
466 Self {
467 pieces: projection
468 .pieces
469 .into_iter()
470 .map(|piece| IngressPiece::from_projection(recording, piece))
471 .collect(),
472 }
473 }
474}
475
476impl Recording {
477 fn from_status(recording: RecordingStatus, projection: &IngressProjection) -> Self {
478 let speaker_review = recording
479 .correction_packet
480 .as_ref()
481 .map(|packet| SpeakerReview {
482 clean: packet.clean,
483 confirmation_state: packet.confirmation_state,
484 observation_count: packet
485 .chunks
486 .iter()
487 .map(|chunk| chunk.observations.len())
488 .sum(),
489 });
490 let awaiting_speaker_review = speaker_review
491 .as_ref()
492 .is_some_and(|review| review.confirmation_state != ConfirmationState::Confirmed);
493 let (mut status, transcription_status, attempt_count, last_error) = match recording.state {
494 RecordingState::Queued => ("uploaded".into(), None, 0, None),
495 RecordingState::Processing { attempt, progress } => (
496 processing_stage(&progress).into(),
497 serde_json::to_value(progress).ok(),
498 i64::from(attempt),
499 None,
500 ),
501 RecordingState::Complete { .. } if awaiting_speaker_review => {
502 ("speaker_review".into(), None, 0, None)
503 }
504 RecordingState::Complete { .. } => ("ready_for_ingress".into(), None, 0, None),
505 RecordingState::Failed {
506 attempts, error, ..
507 } => ("failed".into(), None, i64::from(attempts), Some(error)),
508 };
509 if !projection.pieces.is_empty() {
510 status = if projection
511 .pieces
512 .iter()
513 .all(|piece| piece.phase == "complete")
514 {
515 "complete".into()
516 } else if projection
517 .pieces
518 .iter()
519 .any(|piece| piece.phase == "ingress_failed")
520 {
521 "ingress_failed".into()
522 } else if projection
523 .pieces
524 .iter()
525 .any(|piece| piece.phase == "ingress_in_progress")
526 {
527 "ingressing".into()
528 } else {
529 "ready_for_ingress".into()
530 };
531 }
532 let completed_piece_count = projection
533 .pieces
534 .iter()
535 .filter(|piece| piece.phase == "complete")
536 .count();
537 Self {
538 id: recording.id,
539 sha256: recording.sha256,
540 original_filename: recording.original_filename,
541 content_type: "audio/wav",
542 size_bytes: recording.size_bytes,
543 source_created_at: recording.recorded_at.to_rfc3339(),
544 received_at: recording.received_at.to_rfc3339(),
545 updated_at: recording.received_at.to_rfc3339(),
546 status,
547 transcription_model: recording.transcription_model,
548 reconciliation_model: recording.reconciliation_model,
549 reconciliation_reasoning: recording.reconciliation_reasoning,
550 transcription_status,
551 attempt_count,
552 next_attempt_at: None,
553 last_error,
554 speaker_review,
555 transcript_piece_count: projection.pieces.len(),
556 completed_piece_count,
557 }
558 }
559}
560
561impl IngressPiece {
562 fn from_projection(recording: &RecordingStatus, piece: PieceProjection) -> Self {
563 let record = piece.record;
564 Self {
565 id: record.id,
566 recording_id: recording.id,
567 sha256: recording.sha256.clone(),
568 original_filename: recording.original_filename.clone(),
569 source_created_at: recording.recorded_at.to_rfc3339(),
570 piece_index: piece.piece_index,
571 piece_count: piece.piece_count,
572 transcript_text: piece.transcript_text,
573 estimated_tokens: piece.estimated_tokens,
574 phase: record.phase,
575 provenance_id: record.provenance_id,
576 state: record.state,
577 version: record.version,
578 ingress_failure_count: record.ingress_failure_count,
579 ingress_failures: record.ingress_failures,
580 created_at: record.started_at,
581 updated_at: record.updated_at,
582 }
583 }
584}
585
586fn processing_stage(status: &kcode_audio_ingress::TranscriptionStatus) -> &'static str {
587 let plan_complete = status.steps.iter().any(|entry| {
588 entry.step == kcode_audio_ingress::Step::PlanChunks
589 && entry.state == kcode_audio_ingress::StepState::Completed
590 });
591 if !plan_complete {
592 return "chunking";
593 }
594 let chunks_complete = status
595 .steps
596 .iter()
597 .filter(|entry| {
598 matches!(
599 entry.step,
600 kcode_audio_ingress::Step::TranscribeChunk { .. }
601 )
602 })
603 .all(|entry| entry.state == kcode_audio_ingress::StepState::Completed);
604 if chunks_complete {
605 let analyses_complete = status
606 .steps
607 .iter()
608 .filter(|entry| matches!(entry.step, kcode_audio_ingress::Step::ParseChunk { .. }))
609 .all(|entry| entry.state == kcode_audio_ingress::StepState::Completed);
610 if !analyses_complete {
611 return "analyzing_speakers";
612 }
613 let training_active = status.steps.iter().any(|entry| {
614 entry.step == kcode_audio_ingress::Step::TrainIdentities
615 && matches!(
616 entry.state,
617 kcode_audio_ingress::StepState::Running
618 | kcode_audio_ingress::StepState::Retrying
619 )
620 });
621 if training_active {
622 "training_speakers"
623 } else {
624 "reconciling"
625 }
626 } else {
627 "transcribing"
628 }
629}
630
631fn audio_error(error: kcode_audio_ingress::Error) -> Error {
632 match error.kind() {
633 AudioErrorKind::InvalidInput => Error::new(ErrorKind::InvalidInput, error.to_string()),
634 AudioErrorKind::NotFound => Error::new(ErrorKind::NotFound, error.to_string()),
635 AudioErrorKind::Conflict => Error::new(ErrorKind::Conflict, error.to_string()),
636 AudioErrorKind::Internal => Error::internal(error),
637 }
638}
639
640fn handoff_error(error: HandoffError) -> Error {
641 match error {
642 HandoffError::InvalidInput(message) => Error::new(ErrorKind::InvalidInput, message),
643 HandoffError::NotFound(message) => Error::new(ErrorKind::NotFound, message),
644 HandoffError::Conflict(message) => Error::new(ErrorKind::Conflict, message),
645 HandoffError::Internal(message) => Error::new(ErrorKind::Internal, message),
646 }
647}
648
649#[cfg(test)]
650mod tests {
651 use std::path::{Path, PathBuf};
652
653 use super::*;
654
655 struct TestRoot(PathBuf);
656
657 impl TestRoot {
658 fn new() -> Self {
659 let path = std::env::temp_dir().join(format!(
660 "kcode-audio-session-ingress-test-{}",
661 Uuid::new_v4()
662 ));
663 std::fs::create_dir(&path).unwrap();
664 Self(path)
665 }
666
667 fn path(&self) -> &Path {
668 &self.0
669 }
670 }
671
672 impl Drop for TestRoot {
673 fn drop(&mut self) {
674 let _ = std::fs::remove_dir_all(&self.0);
675 }
676 }
677
678 fn history(root: &Path) -> SessionHistory {
679 SessionHistory::open(kcode_session_history::Config {
680 directory: root.join("active"),
681 completed_list: root.join("completed.txt"),
682 provider_cost_compatibility: None,
683 })
684 .unwrap()
685 }
686
687 fn completed_recording_for_user(
688 user_id: impl Into<String>,
689 transcript: impl Into<String>,
690 ) -> RecordingStatus {
691 let now = Utc::now();
692 RecordingStatus {
693 id: Uuid::new_v4(),
694 user_id: user_id.into(),
695 sha256: "0".repeat(64),
696 original_filename: "meeting.final.WAV".into(),
697 size_bytes: 42,
698 recorded_at: now,
699 received_at: now,
700 transcription_model: "transcription-model".into(),
701 reconciliation_model: "reconciliation-model".into(),
702 reconciliation_reasoning: "xhigh".into(),
703 state: RecordingState::Complete {
704 transcript: transcript.into(),
705 },
706 correction_packet: None,
707 }
708 }
709
710 fn with_speaker_packet(
711 mut recording: RecordingStatus,
712 confirmed_name: Option<&str>,
713 ) -> RecordingStatus {
714 recording.correction_packet = Some(CorrectionPacket {
715 recording_id: recording.id,
716 user_id: recording.user_id.clone(),
717 sha256: recording.sha256.clone(),
718 original_filename: recording.original_filename.clone(),
719 size_bytes: recording.size_bytes,
720 recorded_at: recording.recorded_at,
721 clean: true,
722 chunk_count: 1,
723 chunks: vec![kcode_audio_ingress::CorrectionChunk {
724 chunk_index: 0,
725 chunk_count: 1,
726 audio_start_ms: 0,
727 audio_end_ms: 1_000,
728 raw_gemini_response: "raw".into(),
729 parsed: kcode_audio_ingress::ParsedChunk {
730 utterances: Vec::new(),
731 notes: Vec::new(),
732 clip_valid: true,
733 clip_validity_reason: None,
734 speakers: Vec::new(),
735 },
736 observations: vec![kcode_audio_ingress::CorrectionObservation {
737 local_label: "Speaker A".into(),
738 speaker_ordinal: 0,
739 observation_key: kcode_audio_ingress::ObservationKey {
740 object_id: format!(
741 "kcode-audio-ingress/recording/{}/chunk/0",
742 recording.id
743 ),
744 piece_index: 0,
745 },
746 candidate: None,
747 identified_full_name: None,
748 confirmed_full_name: confirmed_name.map(str::to_owned),
749 }],
750 clean: true,
751 }],
752 confirmation_state: ConfirmationState::Confirmed,
753 });
754 recording
755 }
756
757 #[test]
758 fn recording_filter_is_exactly_scoped_to_the_configured_user() {
759 let own = completed_recording_for_user("own-user", "Own transcript");
760 let foreign = completed_recording_for_user("foreign-user", "Foreign transcript");
761 let visible = [own.clone(), foreign]
762 .into_iter()
763 .filter(|recording| recording_belongs_to(recording, "own-user"))
764 .collect::<Vec<_>>();
765 assert_eq!(visible.len(), 1);
766 assert_eq!(visible[0].id, own.id);
767 }
768
769 #[test]
770 fn cross_user_sha_deduplication_fails_closed() {
771 let mut own = completed_recording_for_user("own-user", "Own transcript");
772 own.sha256 = "a".repeat(64);
773 let mut foreign = completed_recording_for_user("foreign-user", "Foreign transcript");
774 foreign.sha256 = own.sha256.clone();
775
776 assert!(validate_submission_owner(&own, "own-user").is_ok());
777 let error = validate_submission_owner(&foreign, "own-user").unwrap_err();
778 assert_eq!(error.kind(), ErrorKind::Conflict);
779 assert_eq!(
780 error.message(),
781 "identical audio is already attributed to another user"
782 );
783 }
784
785 #[test]
786 fn confirmed_label_retries_must_match_the_complete_mapping() {
787 let recording = with_speaker_packet(
788 completed_recording_for_user("user", "Transcript"),
789 Some("Human Choice"),
790 );
791 let packet = recording.correction_packet.as_ref().unwrap();
792 let key = packet.chunks[0].observations[0].observation_key.clone();
793 let matching = RecordingConfirmation {
794 recording_id: recording.id,
795 observations: vec![kcode_audio_ingress::ObservationConfirmation {
796 observation_key: key.clone(),
797 confirmed_full_name: " Human Choice ".into(),
798 }],
799 };
800 let conflicting = RecordingConfirmation {
801 recording_id: recording.id,
802 observations: vec![kcode_audio_ingress::ObservationConfirmation {
803 observation_key: key,
804 confirmed_full_name: "Different Choice".into(),
805 }],
806 };
807 let incomplete = RecordingConfirmation {
808 recording_id: recording.id,
809 observations: Vec::new(),
810 };
811
812 assert!(confirmation_matches(packet, &matching));
813 assert!(!confirmation_matches(packet, &conflicting));
814 assert!(!confirmation_matches(packet, &incomplete));
815 }
816
817 #[tokio::test]
818 async fn facade_adapts_handoff_projection_and_status_without_drift() {
819 let root = TestRoot::new();
820 let history = history(root.path());
821 let recording = completed_recording_for_user("user", "Transcript");
822 let handoff = Handoff::new(history.clone(), "user".into(), 400).unwrap();
823 handoff
824 .synchronize(std::slice::from_ref(&recording))
825 .await
826 .unwrap();
827
828 let projection = handoff
829 .project(std::slice::from_ref(&recording))
830 .await
831 .unwrap()
832 .remove(0);
833 let projection = IngressProjection::from_handoff(&recording, projection);
834 let pending = Recording::from_status(recording.clone(), &projection);
835 assert_eq!(pending.status, "ready_for_ingress");
836 assert_eq!(pending.transcript_piece_count, 1);
837 assert_eq!(pending.completed_piece_count, 0);
838 assert_eq!(projection.pieces[0].recording_id, recording.id);
839 assert_eq!(projection.pieces[0].sha256, recording.sha256);
840 assert_eq!(projection.pieces[0].piece_index, 0);
841 assert_eq!(projection.pieces[0].piece_count, 1);
842 assert_eq!(projection.pieces[0].transcript_text, "Transcript");
843 assert_eq!(projection.pieces[0].phase, "ingress_pending");
844
845 let record = &projection.pieces[0];
846 history
847 .start_ingress(
848 &record.id,
849 kcode_session_history::StartIngress {
850 expected_version: record.version,
851 provenance_id: "test:facade-adaptation".into(),
852 },
853 )
854 .await
855 .unwrap();
856 let projection = handoff
857 .project(std::slice::from_ref(&recording))
858 .await
859 .unwrap()
860 .remove(0);
861 let projection = IngressProjection::from_handoff(&recording, projection);
862 let ingressing = Recording::from_status(recording, &projection);
863 assert_eq!(ingressing.status, "ingressing");
864 assert_eq!(projection.pieces[0].phase, "ingress_in_progress");
865 }
866
867 #[test]
868 fn handoff_errors_preserve_category_and_message() {
869 let cases = [
870 (
871 HandoffError::InvalidInput("invalid".into()),
872 ErrorKind::InvalidInput,
873 "invalid",
874 ),
875 (
876 HandoffError::NotFound("missing".into()),
877 ErrorKind::NotFound,
878 "missing",
879 ),
880 (
881 HandoffError::Conflict("conflict".into()),
882 ErrorKind::Conflict,
883 "conflict",
884 ),
885 (
886 HandoffError::Internal("raw storage message".into()),
887 ErrorKind::Internal,
888 "raw storage message",
889 ),
890 ];
891
892 for (source, kind, message) in cases {
893 let error = handoff_error(source);
894 assert_eq!(error.kind(), kind);
895 assert_eq!(error.message(), message);
896 }
897 }
898}