diff --git a/README.md b/README.md index cbe339d..433322b 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,7 @@ mcp_bind_address = "127.0.0.1:2769" Action semantics, shared by both transports: - `start` begins recording only when `start_recording_enabled = true`; otherwise the request is rejected, and it is a no-op unless Muninn is idle. - `stop` finishes the active recording and runs the transcription pipeline; no-op when idle. -- `toggle` starts when idle, otherwise stops the active recording. +- `toggle` starts when idle (subject to the same `start_recording_enabled = true` opt-in as `start`, since starting capture is gated regardless of verb), otherwise stops the active recording. - `cancel` discards the active recording without running the pipeline or injecting text. Recording does not stop on its own. An agent typically calls `start`, and a human ends it with the hotkey, a tray click, or an explicit `stop`/`cancel`. Opting in with `start_recording_enabled = true` is the local trust decision for configured agents and scripts; Muninn does not ask for a repeated interactive confirmation for every external start request after that opt-in. diff --git a/src/audio.rs b/src/audio.rs index 0861259..387effd 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -1,3 +1,10 @@ +//! macOS microphone capture via `cpal` and WAV output for the transcription pipeline. +//! +//! [`MacosAudioRecorder`] implements [`AudioRecorder`]: it opens the default input +//! device, buffers PCM in memory, optionally streams resampled frames to a live +//! transcription sink, and writes a temporary WAV on stop. Capture and file I/O are +//! macOS-only; other platforms return [`crate::MacosAdapterError::UnsupportedPlatform`]. + use std::sync::Mutex; use std::time::Instant; @@ -27,6 +34,7 @@ const DEFAULT_STREAMING_FRAME_MS: u16 = 100; #[cfg(target_os = "macos")] use std::sync::Arc; +/// macOS `cpal` recorder that buffers input and writes a temp WAV on stop. #[derive(Default)] pub struct MacosAudioRecorder { #[cfg(target_os = "macos")] @@ -86,6 +94,7 @@ struct CaptureConfigChoice { } impl MacosAudioRecorder { + /// Construct a recorder with the given output [`RecordingConfig`]. #[must_use] pub const fn new(output_config: RecordingConfig) -> Self { Self { @@ -97,6 +106,7 @@ impl MacosAudioRecorder { } } + /// Replace output recording settings; drops a cached capture engine when idle. pub fn set_recording_config(&mut self, output_config: RecordingConfig) { let config_changed = self.output_config != output_config; self.output_config = output_config; @@ -106,6 +116,7 @@ impl MacosAudioRecorder { } } + /// Set the streaming transcription frame duration used when an audio sink is attached. pub fn set_streaming_transcription_config( &mut self, streaming_config: StreamingTranscriptionConfig, @@ -113,6 +124,9 @@ impl MacosAudioRecorder { self.streaming_frame_ms = streaming_config.frame_ms; } + /// Open the default input device and build a paused capture engine. + /// + /// macOS only. Useful to pay device-selection cost before the first recording. pub async fn warm_up(&mut self) -> MacosAdapterResult<()> { #[cfg(target_os = "macos")] { @@ -129,10 +143,19 @@ impl MacosAudioRecorder { #[async_trait(?Send)] impl AudioRecorder for MacosAudioRecorder { + /// Begin capture on the default macOS input device. + /// + /// Delegates to [`AudioRecorder::start_recording_with_audio_sink`] without a + /// streaming sink. async fn start_recording(&mut self) -> MacosAdapterResult<()> { self.start_recording_with_audio_sink(None).await } + /// Begin capture and optionally stream resampled [`AudioFrame`] batches to `sink`. + /// + /// Rebuilds the `cpal` engine when idle and the default input device or + /// [`RecordingConfig`] changed since the last warm-up. Buffered capture is + /// capped at three minutes; overflow is flagged on stop. async fn start_recording_with_audio_sink( &mut self, sink: Option>, @@ -211,6 +234,11 @@ impl AudioRecorder for MacosAudioRecorder { } } + /// Pause capture, render a temp WAV, and return [`RecordedAudio`] metadata. + /// + /// Flushes any partial streaming frame before clearing the sink. When the + /// in-memory buffer overflowed, reported duration reflects capped samples + /// rather than wall-clock elapsed time. async fn stop_recording(&mut self) -> MacosAdapterResult { #[cfg(target_os = "macos")] { @@ -317,6 +345,7 @@ impl AudioRecorder for MacosAudioRecorder { } } + /// Discard the active capture without writing a WAV file. async fn cancel_recording(&mut self) -> MacosAdapterResult<()> { #[cfg(target_os = "macos")] { diff --git a/src/audio/render.rs b/src/audio/render.rs index d22e0d4..30b76da 100644 --- a/src/audio/render.rs +++ b/src/audio/render.rs @@ -1,15 +1,24 @@ +//! PCM resampling, channel downmixing, and WAV serialization for recorded audio. +//! +//! Source buffers from the `cpal` callback are normalized to 16-bit PCM, then +//! transformed to match [`RecordingConfig`] (mono downmix and target sample rate) +//! before writing the temp WAV or streaming [`AudioFrame`] batches. + use crate::config::RecordingConfig; #[cfg(target_os = "macos")] use crate::{MacosAdapterError, MacosAdapterResult}; +/// Convert a normalized float sample to 16-bit PCM, clamping to `[-1.0, 1.0]`. pub(super) fn normalized_f32_to_pcm_i16(sample: f32) -> i16 { (sample.clamp(-1.0, 1.0) * i16::MAX as f32).round() as i16 } +/// Convert 16-bit PCM to a normalized float in `[-1.0, 1.0]`. pub(super) fn pcm_i16_to_normalized_f32(sample: i16) -> f32 { sample as f32 / i16::MAX as f32 } +/// Convert unsigned 16-bit PCM to signed 16-bit PCM. pub(super) fn pcm_u16_to_pcm_i16(sample: u16) -> i16 { normalized_f32_to_pcm_i16((sample as f32 / u16::MAX as f32) * 2.0 - 1.0) } @@ -121,11 +130,15 @@ impl Iterator for OutputSampleIter<'_> { } } +/// Output WAV header fields derived from source channels and [`RecordingConfig`]. pub(super) struct OutputWavSpec { + /// Sample rate in hertz for the rendered WAV. pub(super) sample_rate: u32, + /// Channel count after optional mono downmix. pub(super) channels: u16, } +/// Derive WAV format metadata for the configured output recording settings. pub(super) fn output_wav_spec( source_channels: u16, output_config: &RecordingConfig, @@ -140,6 +153,7 @@ pub(super) fn output_wav_spec( } } +/// Resample, optionally downmix to mono, and encode as 16-bit PCM. pub(super) fn render_output_pcm_i16( samples: &[i16], source_sample_rate: u32, @@ -151,6 +165,10 @@ pub(super) fn render_output_pcm_i16( .collect() } +/// Stable checksum harness for render-path performance benchmarks. +/// +/// Hidden from public rustdoc; returns rendered sample count and a wrapping sum +/// of quantized PCM values so benchmark runs can detect output drift. #[doc(hidden)] #[must_use] pub fn benchmark_render_output_checksum( @@ -182,6 +200,9 @@ pub(super) fn collect_output_samples( OutputSampleIter::new(samples, source_sample_rate, source_channels, output_config).collect() } +/// Write resampled PCM to a unique temp WAV file under the system temp directory. +/// +/// macOS only; used when finalizing [`AudioRecorder::stop_recording`]. #[cfg(target_os = "macos")] pub(super) fn write_wav_file( samples: &[i16], diff --git a/src/autostart.rs b/src/autostart.rs index 688305d..ab93913 100644 --- a/src/autostart.rs +++ b/src/autostart.rs @@ -1,3 +1,9 @@ +//! macOS LaunchAgent synchronization for `app.autostart`. +//! +//! Writes `~/Library/LaunchAgents/com.bnomei.muninn.plist` with the canonical +//! executable path, `MUNINN_CONFIG`, and optional `.env` loading. Does not call +//! `launchctl`; login-session agents are picked up on next login. + use std::fs; use std::io; use std::path::{Path, PathBuf}; @@ -10,15 +16,20 @@ const LAUNCH_AGENT_FILE_NAME: &str = "com.bnomei.muninn.plist"; const DEFAULT_LAUNCH_AGENT_PATH: &str = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; +/// Result of synchronizing the user LaunchAgent with `app.autostart`. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AutostartSyncStatus { + /// LaunchAgent plist written or already matched desired state. Enabled { plist_path: PathBuf, launch_path: PathBuf, + /// `true` when the plist content changed on disk. changed: bool, }, + /// Autostart disabled; plist removed when present. Disabled { plist_path: PathBuf, + /// `true` when an existing plist file was deleted. removed: bool, }, } @@ -31,6 +42,11 @@ struct LaunchAgentSpec { load_dotenv: bool, } +/// Write or remove the Muninn LaunchAgent plist to match `config.app.autostart`. +/// +/// Canonicalizes the executable and config paths so relaunches survive symlinked +/// Homebrew installs. Sets `WorkingDirectory` to the config file parent so +/// relative `.env` loading behaves consistently at login. pub fn sync_autostart(config_path: &Path, config: &AppConfig) -> Result { let home_dir = std::env::var_os("HOME") .map(PathBuf::from) diff --git a/src/config.rs b/src/config.rs index c0a8827..efd208d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,10 @@ +//! TOML-backed application configuration, validation, and per-utterance resolution. +//! +//! [`AppConfig`] is the root schema loaded from `~/.config/muninn/config.toml` (or +//! `MUNINN_CONFIG`). Profiles, voices, and [`ProfileRuleConfig`] matchers layer +//! overrides onto recording, transcription, pipeline, and refine settings before each +//! capture. See [`ResolvedUtteranceConfig`] for the effective settings the runtime uses. + use std::collections::{BTreeMap, HashSet}; use std::env; use std::ffi::OsString; @@ -19,6 +26,7 @@ mod logging; pub use logging::{LoggingConfig, ReplayDetailMode}; +/// Root `config.toml` schema for Muninn runtime, pipeline, and provider settings. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(default, deny_unknown_fields)] pub struct AppConfig { @@ -43,11 +51,13 @@ pub struct AppConfig { } impl AppConfig { + /// Load config from the resolved default path, writing a launchable default when missing. pub fn load() -> Result { let path = resolve_config_path()?; Self::load_or_create_default(path) } + /// Load and validate config from an explicit path. Errors when the file is absent. pub fn load_from_path(path: impl AsRef) -> Result { let path = path.as_ref(); if !path.exists() { @@ -70,6 +80,7 @@ impl AppConfig { Ok(config) } + /// Parse and validate config from a TOML string (used by config hot reload). pub fn from_toml_str(raw: &str) -> Result { let config: Self = toml::from_str(raw).map_err(|source| ConfigError::ParseToml { source })?; @@ -77,6 +88,7 @@ impl AppConfig { Ok(config) } + /// Default config that passes validation and can run without user edits. pub fn launchable_default() -> Self { let mut config = Self::default(); config.pipeline.deadline_ms = 40_000; @@ -102,6 +114,7 @@ impl AppConfig { Self::load_from_path(path) } + /// Check semantic constraints beyond serde deserialization. pub fn validate(&self) -> Result<(), ConfigValidationError> { validate_identifier(self.app.profile.trim(), "app.profile")?; @@ -151,6 +164,7 @@ impl AppConfig { Ok(()) } + /// Pick profile and voice from [`ProfileRuleConfig`] matchers, falling back to [`AppSettings::profile`]. #[must_use] pub fn resolve_profile_selection( &self, @@ -192,6 +206,10 @@ impl AppConfig { } } + /// Merge profile and voice overrides into per-utterance effective settings. + /// + /// Expands the transcription route, applies streaming recording requirements, and + /// materializes transcript prompts before returning [`ResolvedUtteranceConfig`]. #[must_use] pub fn resolve_effective_config( &self, @@ -284,6 +302,7 @@ impl AppConfig { } } +/// Global app behavior flags and the default profile id. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct AppSettings { @@ -328,6 +347,7 @@ impl Default for ExternalControlConfig { } } +/// Hotkey bindings for push-to-talk, done-mode toggle, and capture cancel. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct HotkeysConfig { @@ -358,6 +378,7 @@ impl Default for HotkeysConfig { } } +/// Single hotkey chord with trigger semantics and optional double-tap timing. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct HotkeyBinding { @@ -378,6 +399,7 @@ impl Default for HotkeyBinding { } impl HotkeyBinding { + /// Double-tap window in milliseconds, using the global default when unset. #[must_use] pub fn effective_double_tap_timeout_ms(&self) -> u64 { self.double_tap_timeout_ms @@ -385,15 +407,20 @@ impl HotkeyBinding { } } +/// How a hotkey chord activates capture controls. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum TriggerType { + /// Fire while the chord is held down. Hold, + /// Fire once on chord press. #[default] Press, + /// Fire when the same chord is pressed twice within the timeout window. DoubleTap, } +/// Tray indicator visibility and color palette for capture lifecycle states. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct IndicatorConfig { @@ -413,6 +440,7 @@ impl Default for IndicatorConfig { } } +/// `#RRGGBB` hex colors for each indicator lifecycle state. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct IndicatorColorsConfig { @@ -427,6 +455,7 @@ pub struct IndicatorColorsConfig { } impl IndicatorColorsConfig { + /// Require every configured color to be a valid `#RRGGBB` hex string. pub fn validate(&self) -> Result<(), ConfigValidationError> { for (color_name, color_value) in [ ("indicator.colors.idle", self.idle.as_str()), @@ -465,6 +494,7 @@ impl Default for IndicatorColorsConfig { } } +/// Microphone capture format settings. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct RecordingConfig { @@ -473,6 +503,7 @@ pub struct RecordingConfig { } impl RecordingConfig { + /// Require a positive sample rate. pub fn validate(&self) -> Result<(), ConfigValidationError> { if self.sample_rate_khz == 0 { return Err(ConfigValidationError::RecordingSampleRateKhzMustBePositive); @@ -481,6 +512,7 @@ impl RecordingConfig { Ok(()) } + /// Sample rate in hertz derived from [`RecordingConfig::sample_rate_khz`]. #[must_use] pub const fn sample_rate_hz(&self) -> u32 { self.sample_rate_khz * 1_000 @@ -496,6 +528,7 @@ impl Default for RecordingConfig { } } +/// Post-transcription pipeline deadline, payload shape, and ordered step list. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct PipelineConfig { @@ -514,6 +547,7 @@ impl Default for PipelineConfig { } } +/// External command invoked as one pipeline step after transcription. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct PipelineStepConfig { @@ -529,31 +563,42 @@ pub struct PipelineStepConfig { pub on_error: OnErrorPolicy, } +/// How a pipeline step reads stdin and writes stdout. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum StepIoMode { + /// Infer IO mode from the step command name. #[default] Auto, + /// Expect and emit JSON envelope objects. EnvelopeJson, + /// Pass transcript text through a filter command. TextFilter, } +/// Pipeline behavior when a step exits with an error. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum OnErrorPolicy { + /// Skip the failed step and continue with the previous output. Continue, + /// Substitute the raw transcript and continue downstream steps. FallbackRaw, + /// Stop the pipeline and surface the failure. #[default] Abort, } +/// JSON shape passed between pipeline steps. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum PayloadFormat { + /// Single JSON object envelope per step. #[default] JsonObject, } +/// Confidence thresholds for transcript candidate scoring and acronym handling. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct ScoringConfig { @@ -574,6 +619,7 @@ impl Default for ScoringConfig { } } +/// System prompt fragments used by built-in transcript refinement. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct TranscriptConfig { @@ -622,14 +668,18 @@ impl TranscriptConfig { const MIN_STREAMING_FRAME_MS: u16 = 20; const MAX_STREAMING_FRAME_MS: u16 = 200; +/// Whether utterances are transcribed after capture or incrementally while recording. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum TranscriptionMode { + /// Transcribe the full recording when capture finishes. #[default] Recorded, + /// Stream audio frames to providers during capture. Streaming, } +/// Frame timing and fallback policy for streaming transcription mode. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct StreamingTranscriptionConfig { @@ -658,6 +708,7 @@ impl Default for StreamingTranscriptionConfig { } } +/// Transcription mode, streaming tuning, and optional provider route override. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(default, deny_unknown_fields)] pub struct TranscriptionConfig { @@ -678,6 +729,7 @@ impl TranscriptionConfig { } } +/// Partial overrides for [`StreamingTranscriptionConfig`] applied by profiles. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(default, deny_unknown_fields)] pub struct StreamingTranscriptionOverrides { @@ -716,6 +768,7 @@ impl StreamingTranscriptionOverrides { } } +/// Partial overrides for [`TranscriptionConfig`] applied by profiles. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(default, deny_unknown_fields)] pub struct TranscriptionOverrides { @@ -752,6 +805,7 @@ impl TranscriptionOverrides { } } +/// LLM refine step endpoint, model, and output guardrail limits. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct RefineConfig { @@ -766,6 +820,7 @@ pub struct RefineConfig { } impl RefineConfig { + /// Require non-empty endpoint/model and in-range temperature and ratio guardrails. pub fn validate(&self) -> Result<(), ConfigValidationError> { if self.endpoint.trim().is_empty() { return Err(ConfigValidationError::RefineEndpointMustNotBeEmpty); @@ -810,13 +865,16 @@ impl Default for RefineConfig { } } +/// Backend used by the built-in refine step. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] pub enum RefineProvider { + /// OpenAI chat-completions compatible refine endpoint. #[serde(rename = "openai")] #[default] OpenAi, } +/// Named voice preset that overrides transcript prompts and refine guardrails. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(default, deny_unknown_fields)] pub struct VoiceConfig { @@ -839,6 +897,7 @@ pub struct VoiceConfig { } impl VoiceConfig { + /// Single uppercase ASCII letter for the indicator glyph, when configured. #[must_use] pub fn normalized_indicator_glyph(&self) -> Option { self.indicator_glyph @@ -911,6 +970,7 @@ impl VoiceConfig { } } +/// Profile-level overrides layered onto base config before each utterance. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(default, deny_unknown_fields)] pub struct ProfileConfig { @@ -952,6 +1012,7 @@ impl ProfileConfig { } } +/// Partial overrides for [`RecordingConfig`] applied by profiles. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(default, deny_unknown_fields)] pub struct RecordingOverrides { @@ -979,6 +1040,7 @@ impl RecordingOverrides { } } +/// Partial overrides for [`PipelineConfig`] applied by profiles. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(default, deny_unknown_fields)] pub struct PipelineOverrides { @@ -1017,6 +1079,7 @@ impl PipelineOverrides { } } +/// Partial overrides for [`TranscriptConfig`] applied by profiles. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(default, deny_unknown_fields)] pub struct TranscriptOverrides { @@ -1053,6 +1116,7 @@ impl TranscriptOverrides { } } +/// Partial overrides for [`RefineConfig`] applied by profiles. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(default, deny_unknown_fields)] pub struct RefineOverrides { @@ -1128,6 +1192,7 @@ impl RefineOverrides { } } +/// Context matcher that selects a profile from frontmost app and window metadata. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(default, deny_unknown_fields)] pub struct ProfileRuleConfig { @@ -1194,6 +1259,7 @@ impl ProfileRuleConfig { || self.window_title_contains.is_some() } + /// Return whether every configured matcher agrees with `target_context`. #[must_use] pub fn matches(&self, target_context: &TargetContextSnapshot) -> bool { if !match_optional_exact( @@ -1224,6 +1290,7 @@ impl ProfileRuleConfig { } } +/// Profile and voice chosen for an utterance before overrides are applied. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolvedProfileSelection { pub matched_rule_id: Option, @@ -1233,6 +1300,7 @@ pub struct ResolvedProfileSelection { pub fallback_reason: Option, } +/// Fully resolved per-utterance settings after profile, voice, and route expansion. #[derive(Debug, Clone, PartialEq)] pub struct ResolvedUtteranceConfig { pub target_context: TargetContextSnapshot, @@ -1247,6 +1315,7 @@ pub struct ResolvedUtteranceConfig { } impl ResolvedUtteranceConfig { + /// Streaming-capable providers from the resolved route. Empty when mode is [`TranscriptionMode::Recorded`]. #[must_use] pub fn streaming_transcription_route(&self) -> Vec { if self.effective_config.transcription.mode != TranscriptionMode::Streaming { @@ -1262,6 +1331,7 @@ impl ResolvedUtteranceConfig { } } +/// Materialized built-in transcript and refine settings for pipeline execution. #[derive(Debug, Clone, PartialEq)] pub struct ResolvedBuiltinStepConfig { pub transcript: TranscriptConfig, @@ -1270,6 +1340,7 @@ pub struct ResolvedBuiltinStepConfig { } impl ResolvedBuiltinStepConfig { + /// Build builtin-step settings from an [`AppConfig`], materializing transcript prompts. #[must_use] pub fn from_app_config(config: &AppConfig) -> Self { let mut transcript = config.transcript.clone(); @@ -1283,6 +1354,7 @@ impl ResolvedBuiltinStepConfig { } } +/// Provider-specific credentials and endpoints for each transcription backend. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(default, deny_unknown_fields)] pub struct ProvidersConfig { @@ -1303,6 +1375,7 @@ impl ProvidersConfig { } } +/// macOS Apple Speech on-device transcription settings. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct AppleSpeechProviderConfig { @@ -1332,15 +1405,20 @@ impl Default for AppleSpeechProviderConfig { } } +/// Compute device preference for local `whisper.cpp` inference. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum WhisperCppDevicePreference { + /// Let `whisper.cpp` pick CPU or GPU automatically. #[default] Auto, + /// Force CPU inference. Cpu, + /// Prefer GPU acceleration when available. Gpu, } +/// Local `whisper.cpp` model path and device settings. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct WhisperCppProviderConfig { @@ -1375,6 +1453,7 @@ impl Default for WhisperCppProviderConfig { } } +/// Deepgram batch and streaming transcription API settings. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct DeepgramProviderConfig { @@ -1418,6 +1497,7 @@ impl DeepgramProviderConfig { } } +/// OpenAI batch and realtime transcription API settings. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct OpenAiProviderConfig { @@ -1461,6 +1541,7 @@ impl OpenAiProviderConfig { } } +/// Google Speech-to-Text batch and streaming API settings. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct GoogleProviderConfig { @@ -1520,73 +1601,94 @@ impl GoogleProviderConfig { } } +/// Errors loading, parsing, or bootstrapping the config file. #[derive(Debug, Error)] pub enum ConfigError { + /// `HOME` is unset and no explicit `MUNINN_CONFIG` or `XDG_CONFIG_HOME` path applies. #[error("unable to resolve config path because HOME is not set")] HomeDirectoryNotSet, + /// Explicit load requested a path that does not exist. #[error("config file not found at expected path: {path}")] NotFound { path: PathBuf }, + /// Config file exists but could not be read from disk. #[error("failed to read config file at {path}: {source}")] Read { path: PathBuf, #[source] source: std::io::Error, }, + /// TOML in a known config path failed to deserialize. #[error("failed to parse config TOML at {path}: {source}")] ParseTomlAtPath { path: PathBuf, #[source] source: toml::de::Error, }, + /// TOML string (for example from hot reload) failed to deserialize. #[error("failed to parse config TOML: {source}")] ParseToml { #[source] source: toml::de::Error, }, + /// Parent directory for a new default config could not be created. #[error("failed to create config directory at {path}: {source}")] CreateConfigDir { path: PathBuf, #[source] source: std::io::Error, }, + /// Launchable default config could not be serialized to TOML. #[error("failed to serialize launchable default config: {source}")] SerializeDefaultConfig { #[source] source: toml::ser::Error, }, + /// Serialized default config could not be written to disk. #[error("failed to write default config file at {path}: {source}")] WriteDefaultConfig { path: PathBuf, #[source] source: std::io::Error, }, + /// Parsed config failed [`AppConfig::validate`]. #[error(transparent)] Validation(#[from] ConfigValidationError), } +/// Semantic validation failures surfaced by [`AppConfig::validate`]. #[derive(Debug, Clone, Error, PartialEq, Eq)] pub enum ConfigValidationError { + /// Identifier field (profile, voice, rule id, etc.) is empty or whitespace. #[error("{field_name} must not be empty")] ConfigIdentifierMustNotBeEmpty { field_name: String }, + /// Explicit transcription provider list is present but empty. #[error("{field_name} must include at least one provider")] TranscriptionProvidersMustNotBeEmpty { field_name: String }, + /// Transcription provider list repeats the same provider id. #[error("{field_name} must not contain duplicate providers ({provider_ids:?})")] DuplicateTranscriptionProviders { field_name: String, provider_ids: Vec, }, + /// `providers.apple_speech.locale` is set but empty. #[error("providers.apple_speech.locale must not be empty")] AppleSpeechLocaleMustNotBeEmpty, + /// `providers.whisper_cpp.model` is set but empty. #[error("providers.whisper_cpp.model must not be empty")] WhisperCppModelMustNotBeEmpty, + /// `providers.whisper_cpp.model_dir` is empty. #[error("providers.whisper_cpp.model_dir must not be empty")] WhisperCppModelDirMustNotBeEmpty, + /// `providers.deepgram.endpoint` is empty. #[error("providers.deepgram.endpoint must not be empty")] DeepgramEndpointMustNotBeEmpty, + /// `providers.deepgram.model` is empty. #[error("providers.deepgram.model must not be empty")] DeepgramModelMustNotBeEmpty, + /// `providers.deepgram.language` is empty. #[error("providers.deepgram.language must not be empty")] DeepgramLanguageMustNotBeEmpty, + /// Streaming frame interval is outside the allowed millisecond range. #[error("{field_name} must be between {min} and {max} milliseconds inclusive (got {value})")] StreamingFrameMsOutOfRange { field_name: String, @@ -1594,61 +1696,84 @@ pub enum ConfigValidationError { min: u16, max: u16, }, + /// Streaming finish timeout is zero. #[error("{field_name} must be greater than 0")] StreamingFinishTimeoutMsMustBePositive { field_name: String }, + /// Required provider config string field is empty. #[error("{field_name} must not be empty")] ProviderConfigFieldMustNotBeEmpty { field_name: String }, + /// Required provider config string list is empty. #[error("{field_name} must include at least one value")] ProviderConfigListMustNotBeEmpty { field_name: String }, + /// `pipeline.deadline_ms` is zero. #[error("pipeline.deadline_ms must be greater than 0")] PipelineDeadlineMsMustBePositive, + /// Pipeline step list is empty. #[error("pipeline must include at least one step")] PipelineMustContainAtLeastOneStep, + /// A pipeline step has `timeout_ms` equal to zero. #[error("pipeline step timeout_ms must be greater than 0 (step id: {step_id})")] StepTimeoutMsMustBePositive { step_id: String }, + /// Two pipeline steps share the same `id`. #[error("pipeline step ids must be unique (duplicate id: {step_id})")] DuplicatePipelineStepId { step_id: String }, + /// Hotkey binding has an empty chord. #[error("hotkey chord must not be empty ({hotkey_name})")] HotkeyChordMustNotBeEmpty { hotkey_name: String }, + /// Double-tap hotkey sets `double_tap_timeout_ms` to zero. #[error("double_tap timeout must be greater than 0 ({hotkey_name})")] DoubleTapTimeoutMsMustBePositive { hotkey_name: String }, + /// Indicator color is not a valid `#RRGGBB` hex string. #[error("indicator color must be a #RRGGBB hex string ({color_name}={color_value})")] IndicatorColorMustBeHex { color_name: String, color_value: String, }, + /// `recording.sample_rate_khz` is zero. #[error("recording.sample_rate_khz must be greater than 0")] RecordingSampleRateKhzMustBePositive, + /// `refine.endpoint` is empty. #[error("refine.endpoint must not be empty")] RefineEndpointMustNotBeEmpty, + /// `refine.model` is empty. #[error("refine.model must not be empty")] RefineModelMustNotBeEmpty, + /// `refine.temperature` is negative or non-finite. #[error("refine.temperature must be non-negative")] RefineTemperatureMustBeNonNegative, + /// `refine.max_output_tokens` is zero. #[error("refine.max_output_tokens must be greater than 0")] RefineMaxOutputTokensMustBePositive, + /// Refine ratio guardrail is outside `0.0..=1.0`. #[error("{field_name} must be between 0.0 and 1.0 inclusive (got {value})")] RefineRatioMustBeBetweenZeroAndOne { field_name: String, value: String }, + /// Voice `indicator_glyph` is not exactly one ASCII letter. #[error("voice indicator_glyph must be exactly one ASCII letter ({voice_id}={value})")] VoiceIndicatorGlyphMustBeSingleAsciiLetter { voice_id: String, value: String }, + /// Profile references a voice id missing from `[voices]`. #[error("profile references unknown voice ({profile_id} -> {voice_id})")] UnknownVoiceReference { profile_id: String, voice_id: String, }, + /// Profile rule or profile field references an unknown profile id. #[error("{field_name} references unknown profile ({profile_id})")] UnknownProfileReference { field_name: String, profile_id: String, }, + /// Two profile rules share the same `id`. #[error("profile rule ids must be unique (duplicate id: {rule_id})")] DuplicateProfileRuleId { rule_id: String }, + /// Profile rule defines no bundle, app, or window matchers. #[error("profile rule must include at least one matcher ({rule_id})")] ProfileRuleMustIncludeAtLeastOneMatcher { rule_id: String }, + /// Profile rule matcher field is present but empty. #[error("profile rule field must not be empty ({rule_id}.{field_name})")] ProfileRuleFieldMustNotBeEmpty { rule_id: String, field_name: String }, } +/// Resolve the config file path from `MUNINN_CONFIG`, `XDG_CONFIG_HOME`, or `~/.config/muninn`. pub fn resolve_config_path() -> Result { resolve_config_path_with( |key| env::var_os(key), diff --git a/src/config/logging.rs b/src/config/logging.rs index e968129..a29dcf7 100644 --- a/src/config/logging.rs +++ b/src/config/logging.rs @@ -1,15 +1,21 @@ +//! Replay logging and retention settings nested under `[logging]` in `config.toml`. + use std::path::PathBuf; use serde::{Deserialize, Serialize}; +/// How much detail replay artifacts capture for each utterance. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum ReplayDetailMode { + /// Store compact replay metadata without full debug payloads. #[default] Minimal, + /// Store expanded debug payloads suitable for post-mortem inspection. FullDebug, } +/// Replay capture, storage path, and retention limits for diagnostic artifacts. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct LoggingConfig { diff --git a/src/config_watch.rs b/src/config_watch.rs index 700d113..20aaa2c 100644 --- a/src/config_watch.rs +++ b/src/config_watch.rs @@ -1,3 +1,9 @@ +//! Background pollers that detect config file changes and frontmost-window context shifts. +//! +//! Watchers run on dedicated threads and forward [`UserEvent::ConfigReloaded`], +//! [`UserEvent::ConfigReloadFailed`], and [`UserEvent::PreviewContextUpdated`] into the +//! tao event loop via [`EventLoopProxy`]. + use std::fs; use std::path::{Path, PathBuf}; use std::time::SystemTime; @@ -15,6 +21,11 @@ const PREVIEW_CONTEXT_POLL_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(400); const PREVIEW_CONTEXT_POLL_MAX_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); +/// Poll the config file on a background thread and hot-reload validated [`AppConfig`] changes. +/// +/// Uses metadata fingerprinting before reading file contents, then posts +/// [`UserEvent::ConfigReloaded`] or [`UserEvent::ConfigReloadFailed`] to the event loop. +/// Poll interval backs off when idle and resets after a detected change. pub fn spawn_config_watcher(config_path: PathBuf, proxy: EventLoopProxy) { std::thread::spawn(move || { logging::log_watcher_started("config", Some(config_path.as_path())); @@ -99,6 +110,10 @@ pub fn spawn_config_watcher(config_path: PathBuf, proxy: EventLoopProxy) { std::thread::spawn(move || { logging::log_watcher_started("preview_context", None::<&Path>); @@ -143,6 +158,7 @@ fn next_poll_interval( std::time::Duration::from_millis(capped_ms as u64) } +/// Stable comparison key for [`TargetContextSnapshot`] preview polling. pub(crate) fn preview_context_key( context: &TargetContextSnapshot, ) -> (Option, Option, Option) { @@ -153,13 +169,17 @@ pub(crate) fn preview_context_key( ) } +/// Cheap metadata fingerprint used before reading config file contents. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ConfigFingerprint { + /// Config path does not exist. Missing, + /// File size and modification time observed from metadata. Metadata { modified_at: Option, len: u64, }, + /// Metadata could not be read (permissions, I/O error, etc.). Unreadable(String), } @@ -170,6 +190,7 @@ enum ConfigSnapshot { Unreadable(String), } +/// Read the current [`ConfigFingerprint`] for a config path without loading TOML. pub(crate) fn read_config_fingerprint(path: &Path) -> ConfigFingerprint { match fs::metadata(path) { Ok(metadata) => ConfigFingerprint::Metadata { diff --git a/src/envelope.rs b/src/envelope.rs index 32cc613..2865a3e 100644 --- a/src/envelope.rs +++ b/src/envelope.rs @@ -1,6 +1,16 @@ +//! Versioned pipeline artifact envelope (`muninn.envelope.v1`). +//! +//! Serializable contract between capture, transcription, refinement, and replay. +//! Each section carries an `extra` map so new fields can be added without +//! breaking older readers. + use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +/// Pipeline output document persisted per utterance and replayed offline. +/// +/// Unknown top-level JSON fields deserialize into `extra`. Optional sections +/// default to empty values when omitted from input JSON. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct MuninnEnvelopeV1 { #[serde(default = "default_schema")] @@ -26,8 +36,10 @@ pub struct MuninnEnvelopeV1 { } impl MuninnEnvelopeV1 { + /// Schema identifier written to the `schema` field. pub const SCHEMA: &'static str = "muninn.envelope.v1"; + /// Minimal envelope with required identifiers and empty optional sections. pub fn new(utterance_id: impl Into, started_at: impl Into) -> Self { Self { schema: default_schema(), @@ -44,53 +56,63 @@ impl MuninnEnvelopeV1 { } } + /// Set captured WAV path and duration on the envelope. pub fn with_audio(mut self, wav_path: Option, duration_ms: u64) -> Self { self.audio.wav_path = wav_path; self.audio.duration_ms = duration_ms; self } + /// Set provider raw transcript text. pub fn with_transcript_raw_text(mut self, raw_text: impl Into) -> Self { self.transcript.raw_text = Some(raw_text.into()); self } + /// Record which transcription provider produced `raw_text`. pub fn with_transcript_provider(mut self, provider: impl Into) -> Self { self.transcript.provider = Some(provider.into()); self } + /// Attach the refinement system prompt used after transcription. pub fn with_transcript_system_prompt(mut self, system_prompt: impl Into) -> Self { self.transcript.system_prompt = Some(system_prompt.into()); self } + /// Set the post-processed text ready for injection or replay. pub fn with_output_final_text(mut self, final_text: impl Into) -> Self { self.output.final_text = Some(final_text.into()); self } + /// Append a span the pipeline could not confidently transcribe. pub fn push_uncertain_span(mut self, span: impl Into) -> Self { self.uncertain_spans.push(span.into()); self } + /// Append a refinement candidate considered for a span. pub fn push_candidate(mut self, candidate: impl Into) -> Self { self.candidates.push(candidate.into()); self } + /// Append a replacement applied during post-processing. pub fn push_replacement(mut self, replacement: impl Into) -> Self { self.replacements.push(replacement.into()); self } + /// Append a non-fatal pipeline warning or step error. pub fn push_error(mut self, error: impl Into) -> Self { self.errors.push(error.into()); self } } +/// Captured audio metadata for an utterance. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct EnvelopeAudio { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -101,6 +123,7 @@ pub struct EnvelopeAudio { pub extra: Map, } +/// Transcription inputs and provider output for an utterance. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct EnvelopeTranscript { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -113,6 +136,7 @@ pub struct EnvelopeTranscript { pub extra: Map, } +/// Final injected or clipboard text produced by the pipeline. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct EnvelopeOutput { #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/error.rs b/src/error.rs index 647e64f..fa0444a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,17 +1,30 @@ +//! Typed errors and result alias for macOS adapter operations. +//! +//! [`MacosAdapterError`] covers platform gating, TCC permission preflight, +//! recorder lifecycle, and injection validation. Adapter traits return +//! [`MacosAdapterResult`] so callers can branch on structured failure modes. + use std::fmt; use thiserror::Error; +/// Result type returned by macOS adapter traits and platform helpers. pub type MacosAdapterResult = Result; +/// macOS permission category checked during preflight and surfaced in +/// [`MacosAdapterError::MissingPermissions`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PermissionKind { + /// Microphone access for audio capture. Microphone, + /// Accessibility access for Unicode text injection. Accessibility, + /// Input Monitoring access for global hotkey registration. InputMonitoring, } impl PermissionKind { + /// Stable snake-case label used in error messages and logging. #[must_use] pub const fn as_str(self) -> &'static str { match self { @@ -28,20 +41,39 @@ impl fmt::Display for PermissionKind { } } +fn format_permission_list(permissions: &[PermissionKind]) -> String { + permissions + .iter() + .map(|permission| permission.as_str()) + .collect::>() + .join(", ") +} + +/// Failure modes for platform adapters, preflight, and runtime I/O boundaries. #[derive(Debug, Error, Clone, PartialEq, Eq)] pub enum MacosAdapterError { + /// Muninn adapters are only supported on macOS builds. #[error("unsupported platform")] UnsupportedPlatform, - #[error("missing required permissions: {permissions:?}")] + /// One or more required TCC permissions are not granted. + #[error( + "missing required permissions: {}", + format_permission_list(permissions) + )] MissingPermissions { permissions: Vec }, + /// The hotkey event source closed its channel or stream. #[error("hotkey event stream closed")] HotkeyEventStreamClosed, + /// [`crate::AudioRecorder::start_recording`] called while a capture is already active. #[error("audio recorder is already active")] RecorderAlreadyActive, + /// Stop or cancel called with no active capture. #[error("audio recorder is not active")] RecorderNotActive, + /// [`crate::TextInjector::inject_checked`] rejects whitespace-only payloads. #[error("text injection payload must not be empty")] EmptyInjectionText, + /// Adapter operation failed with a free-form message. #[error("{operation} failed: {message}")] OperationFailed { operation: &'static str, @@ -50,6 +82,7 @@ pub enum MacosAdapterError { } impl MacosAdapterError { + /// Construct an [`MacosAdapterError::OperationFailed`] with the given label. #[must_use] pub fn operation_failed(operation: &'static str, message: impl Into) -> Self { Self::OperationFailed { diff --git a/src/external_control.rs b/src/external_control.rs index 23d01e3..31ea558 100644 --- a/src/external_control.rs +++ b/src/external_control.rs @@ -16,4 +16,4 @@ pub(crate) use action::{parse_url_action, ExternalControlAction, ExternalControl pub(crate) use mcp::spawn_mcp_server; pub(crate) use status::RuntimeStatusHandle; #[cfg(target_os = "macos")] -pub(crate) use url_scheme::install_url_scheme_handler; +pub(crate) use url_scheme::{install_url_scheme_handler, set_url_scheme_enabled}; diff --git a/src/external_control/action.rs b/src/external_control/action.rs index 81b5def..8021cc9 100644 --- a/src/external_control/action.rs +++ b/src/external_control/action.rs @@ -1,3 +1,9 @@ +//! Transport-agnostic external recording-control actions and state resolution. +//! +//! [`ExternalControlAction`] values originate from the `muninn://` URL scheme or +//! MCP tools and are resolved against [`AppState`] before the runtime worker +//! applies the resulting [`AppEvent`]. + use muninn::{AppEvent, AppState}; /// A transport-agnostic recording-control request from an external agent. @@ -13,10 +19,15 @@ pub(crate) enum ExternalControlAction { Cancel, } +/// Result of resolving an [`ExternalControlAction`] against the current state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ExternalControlOutcome { + /// Action maps to an [`AppEvent`] the runtime worker should apply. Enabled(AppEvent), + /// Start or toggle was rejected because `external_control.start_recording_enabled` + /// is false while idle. Disabled, + /// Action is allowed but has no state transition in the current state. Noop, } @@ -61,6 +72,9 @@ impl ExternalControlAction { } } + /// Resolve with start recording enabled and return only an enabled [`AppEvent`]. + /// + /// Treats [`ExternalControlOutcome::Disabled`] like [`ExternalControlOutcome::Noop`]. pub(crate) fn to_app_event(self, state: AppState) -> Option { match self.resolve(state, true) { ExternalControlOutcome::Enabled(app_event) => Some(app_event), @@ -198,6 +212,10 @@ mod tests { ExternalControlAction::Toggle.resolve(AppState::Idle, true), ExternalControlOutcome::Enabled(AppEvent::DoneTogglePressed) ); + assert_eq!( + ExternalControlAction::Toggle.resolve(AppState::Idle, false), + ExternalControlOutcome::Disabled + ); // Regression: a recording started in done mode (hotkey, MCP, or URL // scheme) must stop on the next toggle instead of being a no-op. assert_eq!( diff --git a/src/external_control/mcp.rs b/src/external_control/mcp.rs index 5daa257..b188f32 100644 --- a/src/external_control/mcp.rs +++ b/src/external_control/mcp.rs @@ -1,6 +1,14 @@ //! Localhost streamable-HTTP MCP server exposing recording-control tools. +//! +//! Binds only to explicit loopback addresses from `mcp_bind_address`. Tool calls +//! enqueue [`UserEvent::ExternalControl`] on the tao loop; status probes read +//! [`RuntimeStatusHandle::snapshot`]. use std::net::SocketAddr; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; use rmcp::{ model::{CallToolResult, Content, ServerCapabilities, ServerInfo}, @@ -26,7 +34,7 @@ use crate::runtime_tray::{send_user_event, UserEvent}; pub(crate) struct RecordingControlServer { proxy: EventLoopProxy, status: RuntimeStatusHandle, - start_recording_enabled: bool, + start_recording_enabled: Arc, } #[tool_router] @@ -34,7 +42,7 @@ impl RecordingControlServer { fn new( proxy: EventLoopProxy, status: RuntimeStatusHandle, - start_recording_enabled: bool, + start_recording_enabled: Arc, ) -> Self { Self { proxy, @@ -91,14 +99,8 @@ impl RecordingControlServer { ) )] fn start_recording(&self) -> Result { - if !self.start_recording_enabled { - return Ok(CallToolResult::success(vec![Content::json( - serde_json::json!({ - "status": "disabled", - "action": "start_recording", - "reason": "external_control.start_recording_enabled is false" - }), - )?])); + if let Some(result) = start_recording_disabled_result(&self.start_recording_enabled)? { + return Ok(result); } send_user_event( @@ -174,7 +176,7 @@ pub(crate) fn spawn_mcp_server( proxy: EventLoopProxy, bind_address: String, status: RuntimeStatusHandle, - start_recording_enabled: bool, + start_recording_enabled: Arc, ) { std::thread::spawn(move || { let runtime = match tokio::runtime::Builder::new_current_thread() @@ -214,7 +216,7 @@ async fn serve( proxy: EventLoopProxy, bind_address: String, status: RuntimeStatusHandle, - start_recording_enabled: bool, + start_recording_enabled: Arc, ) { let bind_addr = match validate_bind_address(&bind_address) { Ok(addr) => addr, @@ -233,7 +235,7 @@ async fn serve( Ok(RecordingControlServer::new( proxy.clone(), status.clone(), - start_recording_enabled, + start_recording_enabled.clone(), )) }, LocalSessionManager::default().into(), @@ -263,9 +265,27 @@ async fn serve( } } +fn start_recording_disabled_result( + start_recording_enabled: &AtomicBool, +) -> Result, McpError> { + if start_recording_enabled.load(Ordering::SeqCst) { + return Ok(None); + } + + Ok(Some(CallToolResult::success(vec![Content::json( + serde_json::json!({ + "status": "disabled", + "action": "start_recording", + "reason": "external_control.start_recording_enabled is false" + }), + )?]))) +} + #[cfg(test)] mod tests { - use super::validate_bind_address; + use std::sync::atomic::{AtomicBool, Ordering}; + + use super::{start_recording_disabled_result, validate_bind_address}; #[test] fn accepts_loopback_bind_addresses() { @@ -279,4 +299,22 @@ mod tests { assert!(validate_bind_address("192.168.1.10:2769").is_err()); assert!(validate_bind_address("[::]:2769").is_err()); } + + #[test] + fn start_recording_gate_reads_updated_server_state() { + let state = AtomicBool::new(false); + assert!(start_recording_disabled_result(&state) + .expect("disabled response") + .is_some()); + + state.store(true, Ordering::SeqCst); + assert!(start_recording_disabled_result(&state) + .expect("enabled response") + .is_none()); + + state.store(false, Ordering::SeqCst); + assert!(start_recording_disabled_result(&state) + .expect("disabled response") + .is_some()); + } } diff --git a/src/external_control/status.rs b/src/external_control/status.rs index 5144b72..19a70cd 100644 --- a/src/external_control/status.rs +++ b/src/external_control/status.rs @@ -1,3 +1,9 @@ +//! Thread-safe runtime status snapshots for MCP `get_status` and similar probes. +//! +//! [`RuntimeStatusHandle`] mirrors tray [`IndicatorState`] and permission +//! preflight results into a serde-friendly [`RuntimeStatusSnapshot`] without +//! touching the recorder coordinator. + use std::sync::{Arc, Mutex}; use muninn::{IndicatorState, PermissionPreflightStatus, PermissionStatus}; @@ -6,6 +12,7 @@ use tracing::warn; use crate::logging::TARGET_RUNTIME; +/// JSON-serializable runtime status returned by MCP `get_status`. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub(crate) struct RuntimeStatusSnapshot { pub state: RuntimeStatusState, @@ -16,16 +23,23 @@ pub(crate) struct RuntimeStatusSnapshot { pub failure: Option, } +/// High-level runtime state derived from indicator, permissions, and failures. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum RuntimeStatusState { + /// Ready to accept a recording start; not busy and permissions allow capture. Idle, + /// Microphone capture is active. RecordingActive, + /// Missing credentials indicator or a required permission blocks recording. PermissionBlocked, + /// Pipeline or post-capture work is in progress without an active recording. AlreadyRunning, + /// A runtime failure was recorded; see [`RuntimeStatusSnapshot::failure`]. Failed, } +/// Permission preflight snapshot exposed to external status clients. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub(crate) struct RuntimePermissionSnapshot { pub microphone: RuntimePermissionStatus, @@ -33,6 +47,7 @@ pub(crate) struct RuntimePermissionSnapshot { pub input_monitoring: RuntimePermissionStatus, } +/// macOS permission status for a single capability. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum RuntimePermissionStatus { @@ -43,11 +58,13 @@ pub(crate) enum RuntimePermissionStatus { Unsupported, } +/// Last reported runtime failure message. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub(crate) struct RuntimeFailureSnapshot { pub message: String, } +/// Shared, mutex-protected source of [`RuntimeStatusSnapshot`] values. #[derive(Debug, Clone)] pub(crate) struct RuntimeStatusHandle { inner: Arc>, @@ -61,6 +78,7 @@ struct RuntimeStatusInner { } impl RuntimeStatusHandle { + /// Create a handle with idle indicator state and the given permission preflight. pub(crate) fn new(permissions: PermissionPreflightStatus) -> Self { Self { inner: Arc::new(Mutex::new(RuntimeStatusInner { @@ -71,10 +89,14 @@ impl RuntimeStatusHandle { } } + /// Build the current [`RuntimeStatusSnapshot`] for external clients. pub(crate) fn snapshot(&self) -> RuntimeStatusSnapshot { self.with_inner(snapshot_from_inner) } + /// Update the mirrored tray indicator state. + /// + /// Clears any stored failure snapshot when the indicator leaves [`IndicatorState::Idle`]. pub(crate) fn set_indicator_state(&self, state: IndicatorState) { self.with_inner_mut("set_indicator_state", |inner| { inner.indicator_state = state; @@ -84,12 +106,14 @@ impl RuntimeStatusHandle { }); } + /// Replace the cached permission preflight snapshot. pub(crate) fn set_permissions(&self, permissions: PermissionPreflightStatus) { self.with_inner_mut("set_permissions", |inner| { inner.permissions = permissions; }); } + /// Record a runtime failure and reset the indicator to idle. pub(crate) fn set_failure(&self, message: String) { self.with_inner_mut("set_failure", |inner| { inner.indicator_state = IndicatorState::Idle; diff --git a/src/external_control/url_scheme.rs b/src/external_control/url_scheme.rs index 9e8030b..54ba4a4 100644 --- a/src/external_control/url_scheme.rs +++ b/src/external_control/url_scheme.rs @@ -8,6 +8,7 @@ use std::ffi::CStr; use std::os::raw::c_char; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::OnceLock; use objc2::rc::Retained; @@ -27,6 +28,16 @@ const AE_GET_URL: u32 = 0x4755_524C; // 'GURL' static PROXY: OnceLock> = OnceLock::new(); +static URL_SCHEME_ENABLED: AtomicBool = AtomicBool::new(false); + +/// Enable or disable handling of incoming `muninn://` URLs. +/// +/// When false, parsed URLs are logged and dropped so automation can be turned +/// off via `external_control.url_scheme_enabled` without unregistering the handler. +pub(crate) fn set_url_scheme_enabled(enabled: bool) { + URL_SCHEME_ENABLED.store(enabled, Ordering::SeqCst); +} + define_class!( // SAFETY: // - The superclass NSObject has no subclassing requirements. @@ -60,6 +71,14 @@ fn handle_get_url_event(event: *mut AnyObject) { warn!(target: TARGET_RUNTIME, %url, "ignored unrecognized muninn:// URL"); return; }; + if !URL_SCHEME_ENABLED.load(Ordering::SeqCst) { + warn!( + target: TARGET_RUNTIME, + %url, + "ignored muninn:// URL because external_control.url_scheme_enabled is false" + ); + return; + } let Some(proxy) = PROXY.get() else { warn!(target: TARGET_RUNTIME, "muninn:// handler invoked before proxy was installed"); return; diff --git a/src/hotkeys.rs b/src/hotkeys.rs index bc0fba0..b36cf56 100644 --- a/src/hotkeys.rs +++ b/src/hotkeys.rs @@ -1,3 +1,10 @@ +//! Global hotkey listener that maps configured chords to [`HotkeyEvent`] values. +//! +//! On macOS, a background thread runs `rdev::listen` and forwards press/release +//! events through a bounded tokio channel. Bindings support press, hold, and +//! double-tap modifier triggers from [`HotkeysConfig`]. Non-macOS builds parse +//! config but never emit events. + use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant, SystemTime}; @@ -17,6 +24,7 @@ type PlatformKey = rdev::Key; #[cfg(not(target_os = "macos"))] type PlatformKey = u32; +/// Parsed hotkey binding with trigger semantics resolved from config. #[derive(Debug, Clone)] pub struct MacosHotkeyBinding { action: HotkeyAction, @@ -27,11 +35,13 @@ pub struct MacosHotkeyBinding { double_tap_timeout_ms: u64, } +/// Collection of runtime hotkey bindings built from [`HotkeysConfig`]. #[derive(Debug, Clone)] pub struct MacosHotkeyBindings { bindings: Vec, } +/// Background `rdev` listener bridged to async [`HotkeyEvent`] delivery. #[derive(Debug)] pub struct MacosHotkeyEventSource { receiver: Receiver>, @@ -78,6 +88,7 @@ struct ModifierTapTimes { static HOTKEY_DROP_DIAGNOSTICS: OnceLock> = OnceLock::new(); impl MacosHotkeyBindings { + /// Parse push-to-talk, done-mode toggle, and cancel-capture bindings from config. pub fn from_config(config: &HotkeysConfig) -> MacosAdapterResult { Ok(Self { bindings: vec![ @@ -93,6 +104,10 @@ impl MacosHotkeyBindings { } impl MacosHotkeyEventSource { + /// Spawn the global listener thread and return a channel-backed event source. + /// + /// On macOS, starts `rdev::listen` on a dedicated thread. Non-macOS immediately + /// delivers [`crate::MacosAdapterError::UnsupportedPlatform`]. pub fn from_config(config: &HotkeysConfig) -> MacosAdapterResult { let bindings = MacosHotkeyBindings::from_config(config)?; let (sender, receiver) = channel(HOTKEY_EVENT_BUFFER_CAPACITY); @@ -134,6 +149,10 @@ impl MacosHotkeyEventSource { #[async_trait] impl HotkeyEventSource for MacosHotkeyEventSource { + /// Wait for the next hotkey press or release from the `rdev` listener thread. + /// + /// Events dropped when the bounded queue is full are non-fatal; warnings are + /// rate-limited in the listener thread. async fn next_event(&mut self) -> MacosAdapterResult { match self.receiver.recv().await { Some(result) => result, @@ -143,6 +162,9 @@ impl HotkeyEventSource for MacosHotkeyEventSource { } impl MacosHotkeyEventSource { + /// Poll for a hotkey event without awaiting. + /// + /// Returns `None` when the queue is empty. #[must_use] pub fn try_next_event(&mut self) -> Option> { self.receiver.try_recv().ok() diff --git a/src/injector.rs b/src/injector.rs index bc14308..8750c60 100644 --- a/src/injector.rs +++ b/src/injector.rs @@ -1,11 +1,19 @@ +//! macOS text injection for transcribed output via Core Graphics keyboard events. +//! +//! [`MacosTextInjector`] implements [`TextInjector`] by posting synthetic key-down +//! and key-up events with the UTF-8 payload. Requires Accessibility permission; +//! returns [`MacosAdapterError::MissingPermissions`] when the app is untrusted. + use async_trait::async_trait; use crate::{MacosAdapterError, MacosAdapterResult, TextInjector}; +/// macOS [`TextInjector`] backed by Core Graphics keyboard events. #[derive(Debug, Clone, Copy, Default)] pub struct MacosTextInjector; impl MacosTextInjector { + /// Returns the shared zero-sized injector handle. #[must_use] pub const fn new() -> Self { Self @@ -14,6 +22,10 @@ impl MacosTextInjector { #[async_trait] impl TextInjector for MacosTextInjector { + /// Post the text as a synthetic keyboard event through the HID event tap. + /// + /// macOS only. Prompts for Accessibility access when untrusted; non-macOS + /// returns [`crate::MacosAdapterError::UnsupportedPlatform`]. async fn inject_unicode_text(&self, text: &str) -> MacosAdapterResult<()> { #[cfg(not(target_os = "macos"))] { diff --git a/src/internal_tools.rs b/src/internal_tools.rs index a5df7a9..425d739 100644 --- a/src/internal_tools.rs +++ b/src/internal_tools.rs @@ -1,3 +1,10 @@ +//! Registry and dispatch for Muninn builtin pipeline steps. +//! +//! Maps canonical step command names (STT providers and `refine`) onto +//! in-process executors and `__internal_step` subprocess entry points. Pipeline +//! configuration rewrites builtin commands to envelope-json I/O before the +//! [`muninn::PipelineRunner`] runs. + use std::process::ExitCode; use anyhow::Result; @@ -15,23 +22,34 @@ use crate::{ const INTERNAL_STEP_MARKER: &str = "__internal_step"; +/// High-level category for a builtin pipeline step. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BuiltinStepKind { + /// Speech-to-text provider step. Transcription, + /// Post-transcription transform such as refine. Transform, } +/// Canonical builtin steps recognized by command name. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BuiltinStep { + /// Apple Speech on-device STT. SttAppleSpeech, + /// Local whisper.cpp STT. SttWhisperCpp, + /// Deepgram cloud STT. SttDeepgram, + /// OpenAI cloud STT. SttOpenAi, + /// Google cloud STT. SttGoogle, + /// LLM transcript refinement. Refine, } impl BuiltinStep { + /// Pipeline `cmd` string for this builtin step. pub const fn canonical_name(self) -> &'static str { match self { Self::SttAppleSpeech => "stt_apple_speech", @@ -43,6 +61,7 @@ impl BuiltinStep { } } + /// Return whether this step is transcription or transform. pub const fn kind(self) -> BuiltinStepKind { match self { Self::SttAppleSpeech @@ -54,10 +73,12 @@ impl BuiltinStep { } } + /// True when this step is an STT provider rather than a transform. pub const fn is_transcription(self) -> bool { matches!(self.kind(), BuiltinStepKind::Transcription) } + /// Dispatch subprocess `__internal_step` execution for this builtin. pub fn run_as_internal_tool(self) -> ExitCode { match self { Self::SttAppleSpeech => stt_apple_speech_tool::run_as_internal_tool(), @@ -97,12 +118,14 @@ impl BuiltinStep { } } +/// [`InProcessStepExecutor`] that runs registered builtin steps in-process. #[derive(Debug, Clone)] pub struct BuiltinStepExecutor { config: ResolvedBuiltinStepConfig, } impl BuiltinStepExecutor { + /// Build an executor with resolved builtin-step configuration. pub fn new(config: ResolvedBuiltinStepConfig) -> Self { Self { config } } @@ -120,6 +143,9 @@ impl InProcessStepExecutor for BuiltinStepExecutor { } } +/// Handle `muninn __internal_step ` CLI dispatch when args match. +/// +/// Returns `None` when `args` are not an internal-step invocation. pub fn maybe_handle_internal_step(args: &[String]) -> Option { if args.get(1).map(String::as_str) != Some(INTERNAL_STEP_MARKER) { return None; @@ -138,6 +164,10 @@ pub fn maybe_handle_internal_step(args: &[String]) -> Option { Some(tool.run_as_internal_tool()) } +/// Normalize a builtin `step.cmd` to its canonical name and envelope-json I/O. +/// +/// Returns `Ok(true)` when the command was rewritten, `Ok(false)` for external +/// commands left unchanged. pub fn rewrite_internal_tool_step(step: &mut PipelineStepConfig) -> Result { let Some(tool) = lookup_builtin_step(&step.cmd) else { return Ok(false); @@ -148,10 +178,12 @@ pub fn rewrite_internal_tool_step(step: &mut PipelineStepConfig) -> Result Ok(true) } +/// True when `step.cmd` names a builtin STT provider. pub fn is_transcription_step(step: &PipelineStepConfig) -> bool { lookup_builtin_step(&step.cmd).is_some_and(BuiltinStep::is_transcription) } +/// Resolve a pipeline command string to a [`BuiltinStep`] when recognized. pub fn lookup_builtin_step(raw: &str) -> Option { if let Some(provider) = TranscriptionProvider::lookup_step_name(raw) { return Some(match provider { diff --git a/src/lib.rs b/src/lib.rs index e59b3db..88439c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,12 +25,19 @@ pub mod streaming_transcription; pub mod target_context; pub mod transcription; +/// Tracing target for the tao runtime worker and tray event loop. pub const TARGET_RUNTIME: &str = "runtime"; +/// Tracing target for in-process pipeline steps and orchestration. pub const TARGET_PIPELINE: &str = "pipeline"; +/// Tracing target for transcription provider I/O. pub const TARGET_PROVIDER: &str = "provider"; +/// Tracing target for [`AppConfig`] load and validation. pub const TARGET_CONFIG: &str = "config"; +/// Tracing target for global hotkey registration and events. pub const TARGET_HOTKEY: &str = "hotkey"; +/// Tracing target for audio capture lifecycle. pub const TARGET_RECORDING: &str = "recording"; +/// Fallback tracing target when no subsystem label applies. pub const TARGET_DEFAULT: &str = "default"; #[doc(hidden)] @@ -122,44 +129,64 @@ pub use transcription::{ TranscriptionAttemptOutcome, TranscriptionProvider, TranscriptionRouteSource, }; +/// How an active capture was started. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RecordingMode { + /// Hold-to-talk: release ends capture and starts processing. PushToTalk, + /// Toggle-to-done: second toggle ends capture and starts processing. DoneMode, } +/// Menu-bar indicator phase surfaced to the user during capture and pipeline work. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IndicatorState { + /// No capture or pipeline activity. Idle, + /// Audio capture in progress for the given [`RecordingMode`]. Recording { mode: RecordingMode }, + /// Streaming or batch transcription running. Transcribing, + /// Post-transcription refinement steps running. Pipeline, + /// Final text is being delivered to the target application. Output, + /// A required provider credential is missing from config or environment. MissingCredentials, + /// Capture was discarded without running the pipeline. Cancelled, } impl IndicatorState { + /// Returns `true` for [`IndicatorState::Recording`]. #[must_use] pub const fn is_recording(self) -> bool { matches!(self, Self::Recording { .. }) } + /// Returns `true` during transcription or pipeline refinement. #[must_use] pub const fn is_processing(self) -> bool { matches!(self, Self::Transcribing | Self::Pipeline) } } +/// macOS TCC authorization state for a single permission category. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PermissionStatus { + /// User granted access. Granted, + /// User denied access. Denied, + /// Prompt has not been shown yet. NotDetermined, + /// Parental controls or enterprise policy blocks access. Restricted, + /// Permission is not applicable on this platform build. Unsupported, } +/// Snapshot of microphone, accessibility, and input-monitoring TCC status. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PermissionPreflightStatus { pub microphone: PermissionStatus, @@ -178,6 +205,7 @@ impl Default for PermissionPreflightStatus { } impl PermissionPreflightStatus { + /// Preflight snapshot with every permission marked [`PermissionStatus::Granted`]. #[must_use] pub const fn all_granted() -> Self { Self { @@ -187,6 +215,7 @@ impl PermissionPreflightStatus { } } + /// Preflight snapshot with every permission marked [`PermissionStatus::Unsupported`]. #[must_use] pub const fn unsupported() -> Self { Self { @@ -196,21 +225,25 @@ impl PermissionPreflightStatus { } } + /// Returns `true` when microphone and input monitoring are both granted. #[must_use] pub const fn allows_recording(self) -> bool { status_is_granted(self.microphone) && status_is_granted(self.input_monitoring) } + /// Returns `true` when accessibility is granted for Unicode injection. #[must_use] pub const fn allows_injection(self) -> bool { status_is_granted(self.accessibility) } + /// Returns `true` when input monitoring is granted for global hotkeys. #[must_use] pub const fn allows_hotkeys(self) -> bool { status_is_granted(self.input_monitoring) } + /// Lists permissions that block hotkey-driven recording start. #[must_use] pub fn missing_for_recording(self) -> Vec { let mut missing = Vec::new(); @@ -223,6 +256,11 @@ impl PermissionPreflightStatus { missing } + /// Lists permissions that block tray-initiated recording. + /// + /// Only hard microphone failures block tray start; input monitoring may + /// still be [`PermissionStatus::NotDetermined`] so the user can bootstrap + /// microphone access from the menu. #[must_use] pub fn missing_for_tray_recording(self) -> Vec { let mut missing = Vec::new(); @@ -232,6 +270,7 @@ impl PermissionPreflightStatus { missing } + /// Lists permissions that block text injection. #[must_use] pub fn missing_for_injection(self) -> Vec { let mut missing = Vec::new(); @@ -241,6 +280,8 @@ impl PermissionPreflightStatus { missing } + /// Returns [`MacosAdapterError::MissingPermissions`] when hotkey recording + /// is not allowed. pub fn ensure_recording_allowed(self) -> MacosAdapterResult<()> { let permissions = self.missing_for_recording(); if permissions.is_empty() { @@ -249,6 +290,8 @@ impl PermissionPreflightStatus { Err(MacosAdapterError::MissingPermissions { permissions }) } + /// Returns [`MacosAdapterError::MissingPermissions`] when tray recording + /// is not allowed. pub fn ensure_tray_recording_allowed(self) -> MacosAdapterResult<()> { let permissions = self.missing_for_tray_recording(); if permissions.is_empty() { @@ -257,6 +300,8 @@ impl PermissionPreflightStatus { Err(MacosAdapterError::MissingPermissions { permissions }) } + /// Returns [`MacosAdapterError::MissingPermissions`] when injection is not + /// allowed. pub fn ensure_injection_allowed(self) -> MacosAdapterResult<()> { let permissions = self.missing_for_injection(); if permissions.is_empty() { @@ -277,19 +322,25 @@ const fn status_blocks_recording_start(status: PermissionStatus) -> bool { ) } +/// Logical hotkey binding mapped from configuration. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum HotkeyAction { + /// Hold-to-talk capture. PushToTalk, + /// Toggle done-mode capture on or off. DoneModeToggle, + /// Discard the active capture without running the pipeline. CancelCurrentCapture, } +/// Whether a hotkey binding was pressed or released. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum HotkeyEventKind { Pressed, Released, } +/// Normalized hotkey input delivered by [`HotkeyEventSource`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct HotkeyEvent { pub action: HotkeyAction, @@ -297,22 +348,26 @@ pub struct HotkeyEvent { } impl HotkeyEvent { + /// Pair a [`HotkeyAction`] with a press or release [`HotkeyEventKind`]. #[must_use] pub const fn new(action: HotkeyAction, kind: HotkeyEventKind) -> Self { Self { action, kind } } + /// Returns `true` when `kind` is [`HotkeyEventKind::Pressed`]. #[must_use] pub const fn is_pressed(self) -> bool { matches!(self.kind, HotkeyEventKind::Pressed) } + /// Returns `true` when `kind` is [`HotkeyEventKind::Released`]. #[must_use] pub const fn is_released(self) -> bool { matches!(self.kind, HotkeyEventKind::Released) } } +/// WAV artifact produced when [`AudioRecorder::stop_recording`] completes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RecordedAudio { pub wav_path: PathBuf, @@ -320,6 +375,7 @@ pub struct RecordedAudio { } impl RecordedAudio { + /// Build capture metadata for a finalized WAV artifact. #[must_use] pub fn new(wav_path: impl Into, duration_ms: u64) -> Self { Self { @@ -329,6 +385,7 @@ impl RecordedAudio { } } +/// Mono or interleaved PCM chunk streamed to optional transcription sinks. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AudioFrame { pub samples: Vec, @@ -336,10 +393,16 @@ pub struct AudioFrame { pub channels: u16, } +/// Platform hook for menu-bar or overlay recording indicators. #[async_trait] pub trait IndicatorAdapter: Send + Sync { + /// Prepare native indicator resources before the runtime loop starts. async fn initialize(&mut self) -> MacosAdapterResult<()>; + /// Update the visible indicator phase. async fn set_state(&mut self, state: IndicatorState) -> MacosAdapterResult<()>; + /// Update indicator phase with an optional glyph override. + /// + /// Default implementation ignores `glyph` and delegates to [`IndicatorAdapter::set_state`]. async fn set_state_with_glyph( &mut self, state: IndicatorState, @@ -348,16 +411,14 @@ pub trait IndicatorAdapter: Send + Sync { let _ = glyph; self.set_state(state).await } + /// Show `state` for at least `min_duration`, then revert to `fallback_state`. async fn set_temporary_state( &mut self, state: IndicatorState, min_duration: Duration, fallback_state: IndicatorState, - ) -> MacosAdapterResult<()> { - let _ = min_duration; - let _ = fallback_state; - self.set_state(state).await - } + ) -> MacosAdapterResult<()>; + /// Temporary indicator update with optional primary and fallback glyphs. async fn set_temporary_state_with_glyph( &mut self, state: IndicatorState, @@ -365,34 +426,52 @@ pub trait IndicatorAdapter: Send + Sync { min_duration: Duration, fallback_state: IndicatorState, fallback_glyph: Option, - ) -> MacosAdapterResult<()> { - let _ = glyph; - let _ = fallback_glyph; - self.set_temporary_state(state, min_duration, fallback_state) - .await - } + ) -> MacosAdapterResult<()>; + /// Read the indicator phase last applied by the adapter. async fn state(&self) -> MacosAdapterResult; + /// Returns the glyph currently shown, if any. + /// + /// Default implementation returns `None`. async fn indicator_glyph(&self) -> MacosAdapterResult> { Ok(None) } } +/// Platform hook for macOS TCC permission queries and prompts. #[async_trait] pub trait PermissionsAdapter: Send + Sync { + /// Read current microphone, accessibility, and input-monitoring status. async fn preflight(&self) -> MacosAdapterResult; + /// Prompt for microphone access; returns whether access was granted. async fn request_microphone_access(&self) -> MacosAdapterResult; + /// Prompt for input monitoring access; returns whether access was granted. async fn request_input_monitoring_access(&self) -> MacosAdapterResult; + /// Prompt for accessibility access; returns whether access was granted. async fn request_accessibility_access(&self) -> MacosAdapterResult; } +/// Async source of normalized [`HotkeyEvent`] values from the platform layer. #[async_trait] pub trait HotkeyEventSource: Send { + /// Wait for the next hotkey press or release. + /// + /// Returns [`MacosAdapterError::HotkeyEventStreamClosed`] when the source + /// shuts down. async fn next_event(&mut self) -> MacosAdapterResult; } +/// Platform audio capture boundary used by the runtime coordinator. #[async_trait(?Send)] pub trait AudioRecorder { + /// Begin capturing audio to a WAV file. + /// + /// Returns [`MacosAdapterError::RecorderAlreadyActive`] when a session is + /// already open. async fn start_recording(&mut self) -> MacosAdapterResult<()>; + /// Begin capture and optionally stream PCM frames to `sink`. + /// + /// Default implementation ignores `sink` and delegates to + /// [`AudioRecorder::start_recording`]. async fn start_recording_with_audio_sink( &mut self, sink: Option>, @@ -400,16 +479,28 @@ pub trait AudioRecorder { let _ = sink; self.start_recording().await } + /// Finalize the WAV artifact and return capture metadata. + /// + /// Returns [`MacosAdapterError::RecorderNotActive`] when idle. async fn stop_recording(&mut self) -> MacosAdapterResult; + /// Discard the active capture without producing an artifact. + /// + /// Returns [`MacosAdapterError::RecorderNotActive`] when idle. async fn cancel_recording(&mut self) -> MacosAdapterResult<()>; } +/// Platform hook for delivering finalized text into the focused application. #[async_trait] pub trait TextInjector: Send + Sync { + /// Inject Unicode text through the platform accessibility APIs. async fn inject_unicode_text(&self, text: &str) -> MacosAdapterResult<()>; + /// Inject after rejecting whitespace-only payloads. + /// + /// Returns [`MacosAdapterError::EmptyInjectionText`] when `text` is empty + /// after trimming. async fn inject_checked(&self, text: &str) -> MacosAdapterResult<()> { - if text.is_empty() { + if text.trim().is_empty() { return Err(MacosAdapterError::EmptyInjectionText); } self.inject_unicode_text(text).await diff --git a/src/logging.rs b/src/logging.rs index ffea02d..0a13d20 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,3 +1,8 @@ +//! `tracing` initialization and structured diagnostic helpers. +//! +//! On macOS, routes [`TARGET_*`] constants into unified logging (oslog) by target. +//! [`init_logging`] runs once during bootstrap before the event loop starts. + use anyhow::{anyhow, Result}; use muninn::AppConfig; #[cfg(test)] @@ -11,31 +16,41 @@ use tracing_oslog::OsLogger; #[cfg(target_os = "macos")] use tracing_subscriber::filter::filter_fn; +/// tracing target for runtime lifecycle and worker events. pub const TARGET_RUNTIME: &str = muninn::TARGET_RUNTIME; +/// tracing target for pipeline execution and injection. pub const TARGET_PIPELINE: &str = muninn::TARGET_PIPELINE; +/// tracing target for STT and refine provider calls. pub const TARGET_PROVIDER: &str = muninn::TARGET_PROVIDER; +/// tracing target for config load, reload, and watchers. pub const TARGET_CONFIG: &str = muninn::TARGET_CONFIG; +/// tracing target for global hotkey listener events. pub const TARGET_HOTKEY: &str = muninn::TARGET_HOTKEY; +/// tracing target for audio capture and permission prompts. pub const TARGET_RECORDING: &str = muninn::TARGET_RECORDING; +/// tracing target for logs that do not match a dedicated subsystem target. pub const TARGET_DEFAULT: &str = muninn::TARGET_DEFAULT; #[cfg(target_os = "macos")] const OSLOG_SUBSYSTEM: &str = "com.bnomei.muninn"; +/// Structured diagnostic events captured for tests and mirrored to tracing. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum DiagnosticEvent { + /// Main-thread event-loop proxy rejected a [`UserEvent`] delivery. ProxySendFailed { target: &'static str, context: &'static str, detail: String, }, + /// Background config or preview watcher thread started. WatcherStarted { kind: &'static str, path: Option, }, - ConfigChanged { - path: String, - }, + /// Config file change detected on disk. + ConfigChanged { path: String }, + /// Recording capture began after permissions passed. RecordingStarted { profile_id: String, voice_id: Option, @@ -45,10 +60,8 @@ pub(crate) enum DiagnosticEvent { mono: bool, source: &'static str, }, - RuntimeWorkerFailed { - stage: &'static str, - detail: String, - }, + /// Runtime worker failed to build or exited with an error. + RuntimeWorkerFailed { stage: &'static str, detail: String }, } #[cfg(test)] @@ -68,6 +81,7 @@ fn record_diagnostic_event(event: DiagnosticEvent) { let _ = event; } +/// Log a failed delivery to the tao event-loop proxy. pub(crate) fn log_proxy_send_failed(context: &'static str, detail: impl Into) { let detail = detail.into(); record_diagnostic_event(DiagnosticEvent::ProxySendFailed { @@ -83,6 +97,7 @@ pub(crate) fn log_proxy_send_failed(context: &'static str, detail: impl Into) { let path = path.map(|value| value.display().to_string()); record_diagnostic_event(DiagnosticEvent::WatcherStarted { @@ -99,12 +114,14 @@ pub(crate) fn log_watcher_started(kind: &'static str, path: Option<&std::path::P } } +/// Log detection of an on-disk config file change. pub(crate) fn log_config_changed(path: impl AsRef) { let path = path.as_ref().display().to_string(); record_diagnostic_event(DiagnosticEvent::ConfigChanged { path: path.clone() }); info!(target: TARGET_CONFIG, path, "detected config change"); } +/// Log structured metadata when recording capture starts. pub(crate) fn log_recording_started( profile_id: &str, voice_id: Option<&str>, @@ -136,6 +153,7 @@ pub(crate) fn log_recording_started( ); } +/// Log a fatal runtime worker build or run failure. pub(crate) fn log_runtime_worker_failed(stage: &'static str, detail: impl Into) { let detail = detail.into(); record_diagnostic_event(DiagnosticEvent::RuntimeWorkerFailed { @@ -160,6 +178,10 @@ pub(crate) fn take_diagnostic_events() -> Vec { .collect() } +/// Install the global `tracing` subscriber and emit replay settings. +/// +/// Honors `RUST_LOG` when set; otherwise defaults to `info`. On macOS, mirrors +/// subsystem targets into unified logging (oslog). pub fn init_logging(config: &AppConfig) -> Result<()> { let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let fmt_layer = tracing_subscriber::fmt::layer().with_target(true).compact(); diff --git a/src/main.rs b/src/main.rs index fc06072..5e43e8f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,9 @@ +//! Muninn desktop binary entry point. +//! +//! Initializes tracing, synchronizes macOS autostart state, and hands off to +//! [`AppRuntime`] for the tao event loop. `__internal_step` invocations +//! short-circuit before the menu-bar runtime starts. + use std::fs; use std::path::{Path, PathBuf}; use std::process::ExitCode; @@ -555,13 +561,25 @@ mod tests { } #[test] - fn injection_aborts_after_accessibility_prompt_even_when_now_granted() { - assert!(should_abort_injection( + fn injection_proceeds_after_accessibility_prompt_when_now_granted() { + assert!(!should_abort_injection( PermissionPreflightStatus::all_granted(), true, )); } + #[test] + fn injection_aborts_after_accessibility_prompt_when_still_denied() { + assert!(should_abort_injection( + PermissionPreflightStatus { + microphone: PermissionStatus::Granted, + accessibility: PermissionStatus::Denied, + input_monitoring: PermissionStatus::Granted, + }, + true, + )); + } + #[test] fn injection_continues_when_permissions_are_already_ready() { assert!(!should_abort_injection( diff --git a/src/mock.rs b/src/mock.rs index 7dd2e96..7cc6698 100644 --- a/src/mock.rs +++ b/src/mock.rs @@ -1,3 +1,9 @@ +//! In-memory test doubles for macOS runtime adapter traits. +//! +//! Each mock records call history, queues scripted errors, and implements the +//! same [`IndicatorAdapter`], [`PermissionsAdapter`], [`HotkeyEventSource`], +//! [`AudioRecorder`], and [`TextInjector`] contracts as production adapters. + use std::{ collections::VecDeque, sync::{Arc, Mutex}, @@ -12,6 +18,7 @@ use crate::{ RecordedAudio, TextInjector, }; +/// Thread-safe mock menu-bar indicator with injectable failures. #[derive(Debug, Clone)] pub struct MockIndicatorAdapter { inner: Arc>, @@ -47,6 +54,7 @@ impl Default for MockIndicatorAdapter { } impl MockIndicatorAdapter { + /// Build a mock that starts in [`IndicatorState::Idle`] with no queued errors. #[must_use] pub fn new() -> Self { Self { @@ -54,6 +62,7 @@ impl MockIndicatorAdapter { } } + /// Force the next [`IndicatorAdapter::initialize`] call to return `error`. pub fn set_initialize_error(&self, error: Option) { self.inner .lock() @@ -61,6 +70,7 @@ impl MockIndicatorAdapter { .initialize_error = error; } + /// Queue an error returned before the next successful [`IndicatorAdapter::set_state`]. pub fn enqueue_set_state_error(&self, error: MacosAdapterError) { self.inner .lock() @@ -69,6 +79,7 @@ impl MockIndicatorAdapter { .push_back(error); } + /// Force [`IndicatorAdapter::state`] reads to fail until cleared. pub fn set_state_error(&self, error: Option) { self.inner .lock() @@ -76,6 +87,7 @@ impl MockIndicatorAdapter { .state_error = error; } + /// Count how many times initialize was invoked. #[must_use] pub fn initialize_calls(&self) -> usize { self.inner @@ -84,6 +96,7 @@ impl MockIndicatorAdapter { .initialize_calls } + /// Ordered list of states passed to [`IndicatorAdapter::set_state`]. #[must_use] pub fn state_history(&self) -> Vec { self.inner @@ -126,6 +139,18 @@ impl IndicatorAdapter for MockIndicatorAdapter { self.set_state(fallback_state).await } + async fn set_temporary_state_with_glyph( + &mut self, + state: IndicatorState, + _glyph: Option, + min_duration: Duration, + fallback_state: IndicatorState, + _fallback_glyph: Option, + ) -> MacosAdapterResult<()> { + self.set_temporary_state(state, min_duration, fallback_state) + .await + } + async fn state(&self) -> MacosAdapterResult { let inner = self.inner.lock().expect("indicator mutex poisoned"); match inner.state_error.clone() { @@ -135,6 +160,7 @@ impl IndicatorAdapter for MockIndicatorAdapter { } } +/// Configurable mock for macOS permission preflight and request flows. #[derive(Debug, Clone)] pub struct MockPermissionsAdapter { inner: Arc>, @@ -176,6 +202,7 @@ impl Default for MockPermissionsAdapter { } impl MockPermissionsAdapter { + /// Build a mock that returns default granted preflight status. #[must_use] pub fn new() -> Self { Self { @@ -183,6 +210,7 @@ impl MockPermissionsAdapter { } } + /// Configure the status returned by [`PermissionsAdapter::preflight`]. pub fn set_preflight_status(&self, status: PermissionPreflightStatus) { self.inner .lock() @@ -190,6 +218,7 @@ impl MockPermissionsAdapter { .preflight_result = Ok(status); } + /// Make preflight fail with a fixed adapter error. pub fn set_preflight_error(&self, error: MacosAdapterError) { self.inner .lock() @@ -197,6 +226,7 @@ impl MockPermissionsAdapter { .preflight_result = Err(error); } + /// Count preflight invocations. #[must_use] pub fn preflight_calls(&self) -> usize { self.inner @@ -205,6 +235,7 @@ impl MockPermissionsAdapter { .preflight_calls } + /// Configure the result of [`PermissionsAdapter::request_input_monitoring_access`]. pub fn set_request_input_monitoring_result(&self, granted: bool) { self.inner .lock() @@ -212,6 +243,7 @@ impl MockPermissionsAdapter { .request_input_monitoring_result = Ok(granted); } + /// Make input-monitoring requests fail with a fixed adapter error. pub fn set_request_input_monitoring_error(&self, error: MacosAdapterError) { self.inner .lock() @@ -219,6 +251,7 @@ impl MockPermissionsAdapter { .request_input_monitoring_result = Err(error); } + /// Configure the result of [`PermissionsAdapter::request_microphone_access`]. pub fn set_request_microphone_result(&self, granted: bool) { self.inner .lock() @@ -226,6 +259,7 @@ impl MockPermissionsAdapter { .request_microphone_result = Ok(granted); } + /// Make microphone requests fail with a fixed adapter error. pub fn set_request_microphone_error(&self, error: MacosAdapterError) { self.inner .lock() @@ -233,6 +267,7 @@ impl MockPermissionsAdapter { .request_microphone_result = Err(error); } + /// Configure the result of [`PermissionsAdapter::request_accessibility_access`]. pub fn set_request_accessibility_result(&self, granted: bool) { self.inner .lock() @@ -240,6 +275,7 @@ impl MockPermissionsAdapter { .request_accessibility_result = Ok(granted); } + /// Make accessibility requests fail with a fixed adapter error. pub fn set_request_accessibility_error(&self, error: MacosAdapterError) { self.inner .lock() @@ -247,6 +283,7 @@ impl MockPermissionsAdapter { .request_accessibility_result = Err(error); } + /// Queue a preflight status applied after the next successful permission request. pub fn set_post_request_preflight_status(&self, status: PermissionPreflightStatus) { self.inner .lock() @@ -255,6 +292,7 @@ impl MockPermissionsAdapter { .push_back(Ok(status)); } + /// Count input-monitoring request invocations. #[must_use] pub fn request_input_monitoring_calls(&self) -> usize { self.inner @@ -263,6 +301,7 @@ impl MockPermissionsAdapter { .request_input_monitoring_calls } + /// Count microphone request invocations. #[must_use] pub fn request_microphone_calls(&self) -> usize { self.inner @@ -271,6 +310,7 @@ impl MockPermissionsAdapter { .request_microphone_calls } + /// Count accessibility request invocations. #[must_use] pub fn request_accessibility_calls(&self) -> usize { self.inner @@ -325,6 +365,7 @@ impl PermissionsAdapter for MockPermissionsAdapter { } } +/// FIFO queue mock for deterministic hotkey event delivery in tests. #[derive(Debug, Clone)] pub struct MockHotkeyEventSource { inner: Arc>, @@ -343,6 +384,7 @@ impl Default for MockHotkeyEventSource { } impl MockHotkeyEventSource { + /// Build an empty hotkey source that closes after the queue drains. #[must_use] pub fn new() -> Self { Self { @@ -350,6 +392,7 @@ impl MockHotkeyEventSource { } } + /// Build a source preloaded with successful hotkey events. #[must_use] pub fn with_events(events: impl IntoIterator) -> Self { let source = Self::new(); @@ -357,6 +400,7 @@ impl MockHotkeyEventSource { source } + /// Enqueue a hotkey event returned by the next [`HotkeyEventSource::next_event`]. pub fn push_event(&self, event: HotkeyEvent) { self.inner .lock() @@ -365,6 +409,7 @@ impl MockHotkeyEventSource { .push_back(Ok(event)); } + /// Enqueue an adapter error instead of a hotkey event. pub fn push_error(&self, error: MacosAdapterError) { self.inner .lock() @@ -373,6 +418,7 @@ impl MockHotkeyEventSource { .push_back(Err(error)); } + /// Append multiple successful events to the queue. pub fn extend_events(&self, events: impl IntoIterator) { let mut inner = self.inner.lock().expect("hotkey mutex poisoned"); for event in events { @@ -380,6 +426,7 @@ impl MockHotkeyEventSource { } } + /// Number of queued events or errors not yet consumed. #[must_use] pub fn pending_events(&self) -> usize { self.inner @@ -389,6 +436,7 @@ impl MockHotkeyEventSource { .len() } + /// Count how many times [`HotkeyEventSource::next_event`] was polled. #[must_use] pub fn next_event_calls(&self) -> usize { self.inner @@ -411,6 +459,7 @@ impl HotkeyEventSource for MockHotkeyEventSource { } } +/// Stateful mock audio recorder with queued start/stop/cancel outcomes. #[derive(Debug, Clone)] pub struct MockAudioRecorder { inner: Arc>, @@ -454,6 +503,7 @@ impl Default for MockAudioRecorder { } impl MockAudioRecorder { + /// Build an idle recorder with a default successful stop payload. #[must_use] pub fn new() -> Self { Self { @@ -461,6 +511,7 @@ impl MockAudioRecorder { } } + /// Queue an error returned on the next start attempt. pub fn enqueue_start_error(&self, error: MacosAdapterError) { self.inner .lock() @@ -469,6 +520,7 @@ impl MockAudioRecorder { .push_back(error); } + /// Queue a stop result consumed before the default stop payload. pub fn enqueue_stop_result(&self, result: MacosAdapterResult) { self.inner .lock() @@ -477,6 +529,7 @@ impl MockAudioRecorder { .push_back(result); } + /// Queue an error returned on the next cancel attempt. pub fn enqueue_cancel_error(&self, error: MacosAdapterError) { self.inner .lock() @@ -485,6 +538,7 @@ impl MockAudioRecorder { .push_back(error); } + /// Set the stop result used after the queued stop results are exhausted. pub fn set_default_stop_result(&self, result: MacosAdapterResult) { self.inner .lock() @@ -492,6 +546,7 @@ impl MockAudioRecorder { .default_stop_result = result; } + /// Whether a recording session is currently active. #[must_use] pub fn is_active(&self) -> bool { self.inner @@ -500,6 +555,7 @@ impl MockAudioRecorder { .active } + /// Count start invocations (plain and audio-sink starts). #[must_use] pub fn start_calls(&self) -> usize { self.inner @@ -508,6 +564,7 @@ impl MockAudioRecorder { .start_calls } + /// Count starts that went through [`AudioRecorder::start_recording_with_audio_sink`]. #[must_use] pub fn start_with_audio_sink_calls(&self) -> usize { self.inner @@ -516,6 +573,7 @@ impl MockAudioRecorder { .start_with_audio_sink_calls } + /// Whether each audio-sink start passed `Some` sink sender. #[must_use] pub fn audio_sink_start_history(&self) -> Vec { self.inner @@ -525,6 +583,7 @@ impl MockAudioRecorder { .clone() } + /// Count stop invocations. #[must_use] pub fn stop_calls(&self) -> usize { self.inner @@ -533,6 +592,7 @@ impl MockAudioRecorder { .stop_calls } + /// Count cancel invocations. #[must_use] pub fn cancel_calls(&self) -> usize { self.inner @@ -614,6 +674,7 @@ impl MockAudioRecorder { } } +/// Mock text injector that records injected payloads for assertions. #[derive(Debug, Clone)] pub struct MockTextInjector { inner: Arc>, @@ -633,6 +694,7 @@ impl Default for MockTextInjector { } impl MockTextInjector { + /// Build an injector with no queued errors and an empty payload log. #[must_use] pub fn new() -> Self { Self { @@ -640,6 +702,7 @@ impl MockTextInjector { } } + /// Queue an error returned before the next successful inject. pub fn enqueue_inject_error(&self, error: MacosAdapterError) { self.inner .lock() @@ -648,6 +711,7 @@ impl MockTextInjector { .push_back(error); } + /// All text payloads successfully injected in order. #[must_use] pub fn injected_text(&self) -> Vec { self.inner @@ -657,6 +721,7 @@ impl MockTextInjector { .clone() } + /// Count inject invocations, including those that fail after dequeue. #[must_use] pub fn inject_calls(&self) -> usize { self.inner diff --git a/src/orchestrator.rs b/src/orchestrator.rs index 0503643..6f28390 100644 --- a/src/orchestrator.rs +++ b/src/orchestrator.rs @@ -1,25 +1,39 @@ +//! Post-pipeline injection routing from [`PipelineOutcome`] to text targets. +//! +//! Chooses `output.final_text` when present, otherwise falls back to +//! `transcript.raw_text`, and records why injection was skipped or which stop +//! reason aborted the run. + use crate::envelope::MuninnEnvelopeV1; use crate::runner::{PipelineOutcome, PipelineStopReason}; use serde::Serialize; +/// Stateless helper that maps pipeline outcomes onto injection targets. #[derive(Debug, Clone, Default)] pub struct Orchestrator; +/// Resolved injection target plus diagnostic routing metadata. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct InjectionRoute { pub target: InjectionTarget, pub reason: InjectionRouteReason, + /// Populated for fallback and aborted outcomes; `None` on clean completion. pub pipeline_stop_reason: Option, } +/// Text field selected for clipboard or focused-app injection. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub enum InjectionTarget { + /// Refined or scored final text from `output.final_text`. OutputFinalText(String), + /// Raw transcript fallback from `transcript.raw_text`. TranscriptRawText(String), + /// No non-empty injectable text was available. None, } impl InjectionTarget { + /// Borrow injectable text when the target carries a payload. #[must_use] pub fn text(&self) -> Option<&str> { match self { @@ -29,15 +43,24 @@ impl InjectionTarget { } } +/// Why [`InjectionRoute::target`] was chosen. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum InjectionRouteReason { + /// Non-empty `output.final_text` won over transcript fallback. SelectedOutputFinalText, + /// `output.final_text` was empty; non-empty `transcript.raw_text` was used. SelectedTranscriptRawText, + /// Both candidate text fields were missing or whitespace-only. NoInjectableText, + /// [`PipelineOutcome::Aborted`] — injection is always suppressed. PipelineAborted, } impl Orchestrator { + /// Map `outcome` onto an [`InjectionRoute`] for the text injector. + /// + /// Completed and fallback outcomes share the same final-text-first selection + /// order; aborted outcomes always return [`InjectionTarget::None`]. #[must_use] pub fn route_injection(outcome: &PipelineOutcome) -> InjectionRoute { match outcome { @@ -82,7 +105,7 @@ fn route_envelope( } fn non_empty_text(text: &Option) -> Option<&str> { - text.as_deref().filter(|value| !value.is_empty()) + text.as_deref().filter(|value| !value.trim().is_empty()) } #[cfg(test)] @@ -154,6 +177,42 @@ mod tests { assert_eq!(route.pipeline_stop_reason, Some(abort_reason)); } + #[test] + fn whitespace_only_final_text_falls_back_to_transcript_raw_text() { + let outcome = PipelineOutcome::Completed { + envelope: sample_envelope() + .with_output_final_text(" \n\t") + .with_transcript_raw_text("ship to San Francisco"), + trace: Vec::new(), + }; + + let route = Orchestrator::route_injection(&outcome); + + assert_eq!( + route.target, + InjectionTarget::TranscriptRawText("ship to San Francisco".to_string()) + ); + assert_eq!( + route.reason, + InjectionRouteReason::SelectedTranscriptRawText + ); + } + + #[test] + fn whitespace_only_final_and_raw_text_returns_no_injection() { + let outcome = PipelineOutcome::Completed { + envelope: sample_envelope() + .with_output_final_text(" ") + .with_transcript_raw_text("\n\t"), + trace: Vec::new(), + }; + + let route = Orchestrator::route_injection(&outcome); + + assert_eq!(route.target, InjectionTarget::None); + assert_eq!(route.reason, InjectionRouteReason::NoInjectableText); + } + #[test] fn empty_final_and_raw_text_returns_no_injection() { let outcome = PipelineOutcome::Completed { diff --git a/src/permissions.rs b/src/permissions.rs index 8c0a60f..0cc1ee2 100644 --- a/src/permissions.rs +++ b/src/permissions.rs @@ -1,3 +1,11 @@ +//! macOS TCC permission preflight and request prompts. +//! +//! [`MacosPermissionsAdapter`] implements [`PermissionsAdapter`], querying +//! microphone (`AVAudioApplication`), accessibility (`AXIsProcessTrusted`), and +//! input monitoring (`CGPreflightListenEventAccess`) status. Request methods show +//! system prompts on macOS; non-macOS preflight reports unsupported and requests +//! return [`crate::MacosAdapterError::UnsupportedPlatform`]. + use async_trait::async_trait; #[cfg(not(target_os = "macos"))] @@ -8,10 +16,12 @@ use crate::{ #[cfg(target_os = "macos")] use crate::{MacosAdapterResult, PermissionPreflightStatus, PermissionStatus, PermissionsAdapter}; +/// macOS [`PermissionsAdapter`] for microphone, accessibility, and input monitoring. #[derive(Debug, Clone, Copy, Default)] pub struct MacosPermissionsAdapter; impl MacosPermissionsAdapter { + /// Returns the shared zero-sized permissions adapter. #[must_use] pub const fn new() -> Self { Self @@ -20,6 +30,10 @@ impl MacosPermissionsAdapter { #[async_trait] impl PermissionsAdapter for MacosPermissionsAdapter { + /// Read current TCC status without showing system prompts. + /// + /// On macOS, queries microphone, accessibility, and input-monitoring state. + /// Non-macOS returns [`PermissionPreflightStatus::unsupported`]. async fn preflight(&self) -> MacosAdapterResult { #[cfg(target_os = "macos")] { @@ -32,6 +46,10 @@ impl PermissionsAdapter for MacosPermissionsAdapter { } } + /// Show the macOS microphone consent dialog and return whether access was granted. + /// + /// Blocks until the `AVAudioApplication` completion handler fires. Non-macOS + /// returns [`crate::MacosAdapterError::UnsupportedPlatform`]. async fn request_microphone_access(&self) -> MacosAdapterResult { #[cfg(target_os = "macos")] { @@ -44,6 +62,9 @@ impl PermissionsAdapter for MacosPermissionsAdapter { } } + /// Show the macOS Input Monitoring consent dialog via `CGRequestListenEventAccess`. + /// + /// Non-macOS returns [`crate::MacosAdapterError::UnsupportedPlatform`]. async fn request_input_monitoring_access(&self) -> MacosAdapterResult { #[cfg(target_os = "macos")] { @@ -56,6 +77,9 @@ impl PermissionsAdapter for MacosPermissionsAdapter { } } + /// Prompt for Accessibility trust via `application_is_trusted_with_prompt`. + /// + /// Non-macOS returns [`crate::MacosAdapterError::UnsupportedPlatform`]. async fn request_accessibility_access(&self) -> MacosAdapterResult { #[cfg(target_os = "macos")] { diff --git a/src/platform.rs b/src/platform.rs index 6a086eb..58045b4 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -1,40 +1,62 @@ +//! Compile-time and runtime platform gate for macOS-only adapters. +//! +//! Non-macOS builds compile stub implementations that report +//! [`Platform::Unsupported`] and return +//! [`crate::MacosAdapterError::UnsupportedPlatform`] from [`ensure_supported_platform`]. + #[cfg(not(target_os = "macos"))] use crate::MacosAdapterError; use crate::{MacosAdapterResult, PermissionPreflightStatus}; +/// Host platform detected at compile time and exposed for runtime checks. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Platform { + /// macOS target; adapter implementations are available. Macos, + /// Non-macOS target; adapters return errors or no-op stubs. Unsupported, } +/// Detect the platform for the current build target. +/// +/// Compile-time `cfg` selects the implementation; there is no runtime probing. #[cfg(target_os = "macos")] #[must_use] pub const fn detect_platform() -> Platform { Platform::Macos } +/// Detect the platform for the current build target. +/// +/// Compile-time `cfg` selects the implementation; there is no runtime probing. #[cfg(not(target_os = "macos"))] #[must_use] pub const fn detect_platform() -> Platform { Platform::Unsupported } +/// Returns `true` when the build target is macOS. #[must_use] pub const fn is_supported_platform() -> bool { matches!(detect_platform(), Platform::Macos) } +/// Succeeds on macOS; returns [`crate::MacosAdapterError::UnsupportedPlatform`] elsewhere. #[cfg(target_os = "macos")] pub fn ensure_supported_platform() -> MacosAdapterResult<()> { Ok(()) } +/// Succeeds on macOS; returns [`crate::MacosAdapterError::UnsupportedPlatform`] elsewhere. #[cfg(not(target_os = "macos"))] pub fn ensure_supported_platform() -> MacosAdapterResult<()> { Err(MacosAdapterError::UnsupportedPlatform) } +/// Preflight snapshot with every permission marked [`crate::PermissionStatus::Unsupported`]. +/// +/// Used on non-macOS targets so callers can exercise the same permission API +/// without special-casing the platform. #[must_use] pub const fn unsupported_preflight_status() -> PermissionPreflightStatus { PermissionPreflightStatus::unsupported() diff --git a/src/refine.rs b/src/refine.rs index 2893c0a..e0e52b1 100644 --- a/src/refine.rs +++ b/src/refine.rs @@ -1,3 +1,10 @@ +//! Builtin refine step that lightly corrects `transcript.raw_text`. +//! +//! Calls the configured LLM provider (OpenAI by default), applies acceptance +//! gates on length and token change ratios, and writes accepted output to +//! `output.final_text`. Runnable as a subprocess internal tool or in-process via +//! [`process_input_in_process`]. + use std::collections::HashMap; use std::io::{self, Read, Write}; use std::process::ExitCode; @@ -42,6 +49,7 @@ If the transcript is already acceptable, return it unchanged. Return only the corrected transcript text."#; +/// Refine-step failure surfaced to the pipeline runner or internal-tool stderr. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CliError { code: &'static str, @@ -56,6 +64,7 @@ impl CliError { } } + /// Serialize the error as JSON for subprocess stderr consumers. pub(crate) fn to_stderr_json(&self) -> String { json!({ "error": { @@ -66,6 +75,7 @@ impl CliError { .to_string() } + /// Borrow the human-readable error message. pub(crate) fn message(&self) -> &str { &self.message } @@ -129,6 +139,10 @@ enum CandidateEvaluation { Reject(RejectionMetrics), } +/// Entry point for `muninn __internal_step refine` subprocess invocation. +/// +/// Reads a [`MuninnEnvelopeV1`] from stdin and writes the updated envelope to +/// stdout; failures log and emit JSON on stderr before returning failure. pub fn run_as_internal_tool() -> ExitCode { match run_cli() { Ok(()) => ExitCode::SUCCESS, @@ -222,6 +236,7 @@ where Ok(envelope) } +/// Run refine inside the pipeline process using resolved builtin configuration. pub(crate) async fn process_input_in_process( input: &MuninnEnvelopeV1, config: &ResolvedBuiltinStepConfig, diff --git a/src/replay.rs b/src/replay.rs index 3c33450..2d3d72e 100644 --- a/src/replay.rs +++ b/src/replay.rs @@ -1,3 +1,9 @@ +//! Disk-backed replay artifacts for utterance debugging. +//! +//! Writes redacted `record.json` trees under `logging.replay_dir`, optionally +//! retaining audio via hard link. Prunes by retention age and max byte budget on +//! a throttled schedule. Invoked from the replay persistence worker thread. + use std::collections::HashSet; use std::env; use std::fs; @@ -33,10 +39,14 @@ const SECRET_KEYS: &[&str] = &[ "google_stt_token", ]; +/// On-disk location and retention limits for replay artifacts. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct ReplayStoreSpec { + /// Absolute replay root directory. pub(crate) root: PathBuf, + /// Maximum age in days before artifacts are eligible for pruning. pub(crate) retention_days: u32, + /// Maximum total bytes to retain across artifact directories. pub(crate) max_bytes: u64, } @@ -86,6 +96,7 @@ struct ReplayEntryMeta { bytes: u64, } +/// Mutable replay index with throttled pruning for one root directory. pub(crate) struct ReplayStore { root: PathBuf, retention_days: u32, @@ -94,6 +105,7 @@ pub(crate) struct ReplayStore { entries: Vec, } +/// Resolve replay directory and retention limits from effective logging config. pub(crate) fn replay_store_spec(config: &AppConfig) -> Result { Ok(ReplayStoreSpec { root: expand_replay_dir(&config.logging.replay_dir) @@ -104,6 +116,7 @@ pub(crate) fn replay_store_spec(config: &AppConfig) -> Result { } impl ReplayStore { + /// Open or create a replay store and index existing artifact directories. pub(crate) fn open(spec: ReplayStoreSpec) -> Result { fs::create_dir_all(&spec.root) .with_context(|| format!("creating replay root {}", spec.root.display()))?; @@ -117,11 +130,15 @@ impl ReplayStore { }) } + /// Apply updated retention limits without reopening the store. pub(crate) fn update_limits(&mut self, spec: &ReplayStoreSpec) { self.retention_days = spec.retention_days; self.max_bytes = spec.max_bytes; } + /// Write one replay artifact when `logging.replay_enabled` is true. + /// + /// Returns `None` when replay is disabled or persistence is skipped. pub(crate) fn persist( &mut self, resolved: ResolvedUtteranceConfig, @@ -190,6 +207,10 @@ impl ReplayStore { } } +/// One-shot convenience wrapper that opens a store and persists one utterance. +/// +/// Prefer [`ReplayStore`] via [`crate::replay_dispatch::ReplayPersistenceService`] +/// in the hot path. #[allow(dead_code)] pub fn persist_replay( resolved: ResolvedUtteranceConfig, @@ -358,7 +379,7 @@ fn replay_refine_context(config: &AppConfig) -> Option { } Some(ReplayRefineContext { - system_prompt: system_prompt.to_string(), + system_prompt: REDACTED_VALUE.to_string(), }) } @@ -1057,7 +1078,7 @@ mod tests { } #[test] - fn persist_replay_includes_system_prompt_only_in_refine_context_when_refine_is_present() { + fn persist_replay_redacts_refine_prompt_but_records_that_refine_was_present() { let root = temp_dir("persist-refine"); let mut config = AppConfig::launchable_default(); config.logging.replay_enabled = true; @@ -1104,9 +1125,10 @@ mod tests { ) .expect("parse replay record"); + assert!(!config.transcript.system_prompt.trim().is_empty()); assert_eq!( record["refine_context"]["system_prompt"], - Value::String(config.transcript.system_prompt) + Value::String(REDACTED_VALUE.to_string()) ); assert_eq!( record["input_envelope"]["transcript"].get("system_prompt"), diff --git a/src/replay_dispatch.rs b/src/replay_dispatch.rs index 518f2b3..bf4c1d3 100644 --- a/src/replay_dispatch.rs +++ b/src/replay_dispatch.rs @@ -1,3 +1,9 @@ +//! Background worker that offloads replay persistence from the hot path. +//! +//! The runtime worker enqueues replay requests on a bounded sync channel; a +//! dedicated thread owns [`ReplayStore`] instances and deletes temporary WAV +//! files after persistence completes. + use std::collections::HashMap; use std::sync::mpsc::{sync_channel, SyncSender, TrySendError}; use std::thread::JoinHandle; @@ -19,74 +25,23 @@ struct ReplayPersistRequest { recorded: RecordedAudio, } +/// Bounded-queue replay writer running on a background OS thread. pub(crate) struct ReplayPersistenceService { sender: Option>, worker: Option>, } impl ReplayPersistenceService { + /// Start the replay persistence worker thread. pub(crate) fn spawn() -> Self { let (sender, receiver) = sync_channel::(REPLAY_PERSIST_QUEUE_CAPACITY); let worker = std::thread::spawn(move || { let mut stores = HashMap::::new(); while let Ok(request) = receiver.recv() { - let spec = match replay::replay_store_spec(&request.resolved.effective_config) { - Ok(spec) => spec, - Err(error) => { - warn!( - target: logging::TARGET_RUNTIME, - error = %error, - "failed to resolve replay store configuration" - ); - continue; - } - }; - - let store = match stores.entry(spec.root.clone()) { - std::collections::hash_map::Entry::Occupied(mut entry) => { - entry.get_mut().update_limits(&spec); - entry.into_mut() - } - std::collections::hash_map::Entry::Vacant(entry) => { - match replay::ReplayStore::open(spec.clone()) { - Ok(store) => entry.insert(store), - Err(error) => { - warn!( - target: logging::TARGET_RUNTIME, - replay_root = %spec.root.display(), - error = %error, - "failed to initialize replay persistence store" - ); - continue; - } - } - } - }; - - match store.persist( - request.resolved, - request.envelope, - request.outcome, - request.route, - request.recorded, - ) { - Ok(Some(path)) => { - info!( - target: logging::TARGET_RUNTIME, - replay_dir = %path.display(), - "persisted replay artifact" - ); - } - Ok(None) => {} - Err(error) => { - warn!( - target: logging::TARGET_RUNTIME, - error = %error, - "failed to persist replay artifact" - ); - } - } + let wav_path = request.recorded.wav_path.clone(); + persist_request(&mut stores, request); + crate::runtime_pipeline::cleanup_recording_file(&wav_path); } }); @@ -96,6 +51,11 @@ impl ReplayPersistenceService { } } + /// Queue replay persistence for one utterance. + /// + /// Returns `true` when the worker accepted the request and owns temporary + /// WAV cleanup. Returns `false` when replay is disabled, the queue is full, + /// or the worker is unavailable; in those cases the caller retains cleanup. pub(crate) fn enqueue( &self, resolved: Option, @@ -103,16 +63,18 @@ impl ReplayPersistenceService { outcome: PipelineOutcome, route: InjectionRoute, recorded: RecordedAudio, - ) { - let Some(resolved) = resolved else { - return; + ) -> bool { + let Some(resolved) = + resolved.filter(|resolved| resolved.effective_config.logging.replay_enabled) + else { + return false; }; let Some(sender) = self.sender.as_ref() else { warn!( target: logging::TARGET_RUNTIME, "dropping replay persistence request because service is shut down" ); - return; + return false; }; let request = ReplayPersistRequest { resolved, @@ -122,24 +84,88 @@ impl ReplayPersistenceService { recorded, }; match sender.try_send(request) { - Ok(()) => {} + Ok(()) => true, Err(TrySendError::Full(_request)) => { warn!( target: logging::TARGET_RUNTIME, queue_capacity = REPLAY_PERSIST_QUEUE_CAPACITY, "dropping replay persistence request because replay queue is full" ); + false } Err(TrySendError::Disconnected(_request)) => { warn!( target: logging::TARGET_RUNTIME, "dropping replay persistence request because replay worker is unavailable" ); + false } } } } +fn persist_request( + stores: &mut HashMap, + request: ReplayPersistRequest, +) { + let spec = match replay::replay_store_spec(&request.resolved.effective_config) { + Ok(spec) => spec, + Err(error) => { + warn!( + target: logging::TARGET_RUNTIME, + error = %error, + "failed to resolve replay store configuration" + ); + return; + } + }; + + let store = match stores.entry(spec.root.clone()) { + std::collections::hash_map::Entry::Occupied(mut entry) => { + entry.get_mut().update_limits(&spec); + entry.into_mut() + } + std::collections::hash_map::Entry::Vacant(entry) => { + match replay::ReplayStore::open(spec.clone()) { + Ok(store) => entry.insert(store), + Err(error) => { + warn!( + target: logging::TARGET_RUNTIME, + replay_root = %spec.root.display(), + error = %error, + "failed to initialize replay persistence store" + ); + return; + } + } + } + }; + + match store.persist( + request.resolved, + request.envelope, + request.outcome, + request.route, + request.recorded, + ) { + Ok(Some(path)) => { + info!( + target: logging::TARGET_RUNTIME, + replay_dir = %path.display(), + "persisted replay artifact" + ); + } + Ok(None) => {} + Err(error) => { + warn!( + target: logging::TARGET_RUNTIME, + error = %error, + "failed to persist replay artifact" + ); + } + } +} + impl Drop for ReplayPersistenceService { fn drop(&mut self) { let _ = self.sender.take(); @@ -154,3 +180,199 @@ impl Drop for ReplayPersistenceService { } } } + +#[cfg(test)] +mod tests { + use super::*; + use muninn::{ + AppConfig, InjectionRouteReason, InjectionTarget, PipelineTraceEntry, + ResolvedBuiltinStepConfig, ResolvedTranscriptionRoute, TargetContextSnapshot, + TranscriptionRouteSource, + }; + use std::fs; + use std::path::{Path, PathBuf}; + use std::sync::mpsc::{sync_channel, TryRecvError}; + + fn temp_dir(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "muninn-replay-dispatch-test-{}-{}-{}", + name, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock after unix epoch") + .as_nanos() + )); + fs::create_dir_all(&path).expect("create temp dir"); + path + } + + fn sample_resolved(root: &Path) -> ResolvedUtteranceConfig { + let mut effective_config = AppConfig::launchable_default(); + effective_config.logging.replay_enabled = true; + effective_config.logging.replay_dir = root.to_path_buf(); + ResolvedUtteranceConfig { + target_context: TargetContextSnapshot { + bundle_id: Some("com.openai.codex".to_string()), + app_name: Some("Codex".to_string()), + window_title: Some("replay dispatch test".to_string()), + captured_at: "2026-03-06T10:00:00Z".to_string(), + }, + matched_rule_id: Some("codex".to_string()), + profile_id: "default".to_string(), + voice_id: Some("codex_focus".to_string()), + voice_glyph: Some('C'), + fallback_reason: None, + transcription_route: ResolvedTranscriptionRoute { + providers: Vec::new(), + source: TranscriptionRouteSource::PipelineInferred, + }, + builtin_steps: ResolvedBuiltinStepConfig::from_app_config(&effective_config), + effective_config, + } + } + + fn sample_envelope() -> MuninnEnvelopeV1 { + MuninnEnvelopeV1::new("utt-replay-dispatch", "2026-03-05T22:30:00Z") + .with_audio(Some("/tmp/input.wav".to_string()), 1450) + .with_transcript_raw_text("hello") + .with_output_final_text("HELLO") + } + + fn sample_outcome() -> PipelineOutcome { + PipelineOutcome::Completed { + envelope: sample_envelope(), + trace: Vec::::new(), + } + } + + fn sample_route() -> InjectionRoute { + InjectionRoute { + target: InjectionTarget::OutputFinalText("HELLO".to_string()), + reason: InjectionRouteReason::SelectedOutputFinalText, + pipeline_stop_reason: None, + } + } + + fn recorded_wav(root: &Path, name: &str) -> RecordedAudio { + let path = root.join(name); + fs::write(&path, b"wav").expect("write test wav"); + RecordedAudio::new(path, 1450) + } + + fn sample_request(root: &Path, wav_name: &str) -> ReplayPersistRequest { + ReplayPersistRequest { + resolved: sample_resolved(root), + envelope: sample_envelope(), + outcome: sample_outcome(), + route: sample_route(), + recorded: recorded_wav(root, wav_name), + } + } + + #[test] + fn enqueue_returns_true_when_worker_accepts_wav_cleanup_ownership() { + let root = temp_dir("accepted"); + let (sender, receiver) = sync_channel(1); + let service = ReplayPersistenceService { + sender: Some(sender), + worker: None, + }; + let recorded = recorded_wav(&root, "accepted.wav"); + let wav_path = recorded.wav_path.clone(); + + let accepted = service.enqueue( + Some(sample_resolved(&root)), + sample_envelope(), + sample_outcome(), + sample_route(), + recorded, + ); + + assert!(accepted); + let queued = receiver.try_recv().expect("worker queue owns request"); + assert_eq!(queued.recorded.wav_path, wav_path); + assert!(wav_path.exists()); + } + + #[test] + fn enqueue_returns_false_and_caller_keeps_wav_cleanup_when_queue_is_full() { + let root = temp_dir("full"); + let (sender, receiver) = sync_channel(1); + sender + .try_send(sample_request(&root, "already-queued.wav")) + .expect("pre-fill queue"); + let service = ReplayPersistenceService { + sender: Some(sender), + worker: None, + }; + let recorded = recorded_wav(&root, "rejected-full.wav"); + let wav_path = recorded.wav_path.clone(); + + let accepted = service.enqueue( + Some(sample_resolved(&root)), + sample_envelope(), + sample_outcome(), + sample_route(), + recorded, + ); + + assert!(!accepted); + assert!(wav_path.exists(), "caller still owns rejected WAV cleanup"); + let queued = receiver + .try_recv() + .expect("pre-filled request remains queued"); + assert_eq!(queued.recorded.wav_path, root.join("already-queued.wav")); + assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); + } + + #[test] + fn enqueue_returns_false_and_caller_keeps_wav_cleanup_when_worker_is_disconnected() { + let root = temp_dir("disconnected"); + let (sender, receiver) = sync_channel(1); + drop(receiver); + let service = ReplayPersistenceService { + sender: Some(sender), + worker: None, + }; + let recorded = recorded_wav(&root, "rejected-disconnected.wav"); + let wav_path = recorded.wav_path.clone(); + + let accepted = service.enqueue( + Some(sample_resolved(&root)), + sample_envelope(), + sample_outcome(), + sample_route(), + recorded, + ); + + assert!(!accepted); + assert!(wav_path.exists(), "caller still owns rejected WAV cleanup"); + } + + #[test] + fn enqueue_returns_false_and_caller_keeps_wav_cleanup_when_replay_is_disabled() { + let root = temp_dir("disabled"); + let (sender, receiver) = sync_channel(1); + let service = ReplayPersistenceService { + sender: Some(sender), + worker: None, + }; + let mut resolved = sample_resolved(&root); + resolved.effective_config.logging.replay_enabled = false; + let recorded = recorded_wav(&root, "rejected-disabled.wav"); + let wav_path = recorded.wav_path.clone(); + + let accepted = service.enqueue( + Some(resolved), + sample_envelope(), + sample_outcome(), + sample_route(), + recorded, + ); + + assert!(!accepted); + assert!(wav_path.exists(), "caller still owns rejected WAV cleanup"); + assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); + } +} diff --git a/src/runner.rs b/src/runner.rs index 6064c1d..2d048b4 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,3 +1,11 @@ +//! Sequential pipeline runner for [`MuninnEnvelopeV1`] post-processing steps. +//! +//! Each configured [`PipelineStepConfig`] runs against the current envelope, +//! honoring per-step timeouts, a global [`PipelineConfig::deadline_ms`], and +//! [`OnErrorPolicy`] recovery. Builtin steps may execute in-process via +//! [`InProcessStepExecutor`]; all others spawn external commands through +//! `execution` and `transport`, with stdin/stdout shaped by `codec`. + use std::sync::Arc; use std::time::{Duration, Instant}; @@ -15,14 +23,23 @@ const MAX_STEP_STDOUT_BYTES: usize = 64 * 1024; const MAX_STEP_STDERR_BYTES: usize = 16 * 1024; const TRUNCATION_SUFFIX: &str = "\n[truncated]"; +/// Executes a configured pipeline and returns a traced [`PipelineOutcome`]. #[derive(Clone)] pub struct PipelineRunner { strict_step_contract: bool, in_process_step_executor: Option>, } +/// Optional hook for builtin steps that run inside the runner process. +/// +/// Returns `None` when the step command is not handled in-process, allowing +/// the runner to fall back to external subprocess execution. #[async_trait] pub trait InProcessStepExecutor: Send + Sync { + /// Attempt in-process execution for `step`. + /// + /// `None` means the executor does not handle this command; `Some(Err(_))` + /// records a step failure with the returned [`InProcessStepError`]. async fn try_execute( &self, step: &PipelineStepConfig, @@ -30,11 +47,16 @@ pub trait InProcessStepExecutor: Send + Sync { ) -> Option>; } +/// Failure details surfaced by an in-process step executor. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InProcessStepError { + /// Maps to the same [`StepFailureKind`] taxonomy used for external steps. pub kind: StepFailureKind, + /// Human-readable failure summary for trace and abort reasons. pub message: String, + /// Provider stderr captured for diagnostics (often JSON for builtin tools). pub stderr: String, + /// Simulated exit status when the builtin tool would have exited non-zero. pub exit_status: Option, } @@ -48,6 +70,11 @@ impl Default for PipelineRunner { } impl PipelineRunner { + /// Build a runner with no in-process executor. + /// + /// When `strict_step_contract` is false, envelope-json steps that emit + /// invalid stdout keep the input envelope and mark + /// [`PipelinePolicyApplied::ContractBypass`] instead of failing. pub fn new(strict_step_contract: bool) -> Self { Self { strict_step_contract, @@ -55,6 +82,7 @@ impl PipelineRunner { } } + /// Build a runner that tries `in_process_step_executor` before spawning subprocesses. pub fn with_in_process_step_executor( strict_step_contract: bool, in_process_step_executor: Arc, @@ -65,6 +93,8 @@ impl PipelineRunner { } } + /// Run every step in `config` against `envelope`, stopping early on abort + /// policies, fallback policies, or an exhausted global deadline. pub async fn run( &self, envelope: MuninnEnvelopeV1, @@ -249,29 +279,45 @@ impl PipelineRunner { } } +/// Terminal result of a pipeline run, including per-step trace metadata. #[derive(Debug, Clone, PartialEq, Serialize)] pub enum PipelineOutcome { + /// All steps finished without an aborting error policy. Completed { + /// Final envelope after the last successful step. envelope: MuninnEnvelopeV1, + /// Per-step timing, exit status, and policy annotations. trace: Vec, }, + /// Pipeline stopped early but retained the last envelope for injection. + /// + /// Triggered by [`OnErrorPolicy::FallbackRaw`], a global deadline during a + /// step, or a deadline hit before the next step starts. FallbackRaw { + /// Envelope at the point of fallback (may be pre-failure input). envelope: MuninnEnvelopeV1, trace: Vec, reason: PipelineStopReason, }, + /// Pipeline halted with no injectable envelope. + /// + /// Produced when a step fails under [`OnErrorPolicy::Abort`]. Aborted { trace: Vec, reason: PipelineStopReason, }, } +/// Why a pipeline returned [`PipelineOutcome::FallbackRaw`] or [`PipelineOutcome::Aborted`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub enum PipelineStopReason { + /// Global [`PipelineConfig::deadline_ms`] budget was exhausted. GlobalDeadlineExceeded { deadline_ms: u64, + /// Step running or about to start when the deadline fired. step_id: Option, }, + /// A single step failed and its [`OnErrorPolicy`] ended the run. StepFailed { step_id: String, failure: StepFailureKind, @@ -279,34 +325,56 @@ pub enum PipelineStopReason { }, } +/// Diagnostics for one executed pipeline step. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct PipelineTraceEntry { + /// Step identifier from configuration. pub id: String, + /// Wall-clock duration of the step attempt in milliseconds. pub duration_ms: u64, + /// True when the step hit its per-step timeout budget. pub timed_out: bool, + /// Step exit code when available; `None` when timeout prevented a status. pub exit_status: Option, + /// Error-recovery or contract-bypass policy recorded for this step. pub policy_applied: PipelinePolicyApplied, + /// Captured stderr (truncated when it exceeds the runner capture budget). pub stderr: String, } +/// Recovery or contract annotation attached to a [`PipelineTraceEntry`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum PipelinePolicyApplied { + /// Step succeeded without special handling. None, + /// Non-strict mode kept the input envelope after invalid step stdout. ContractBypass, + /// Step failed under [`OnErrorPolicy::Continue`] and the pipeline continued. Continue, + /// Step failure triggered [`OnErrorPolicy::FallbackRaw`]. FallbackRaw, + /// Step failure triggered [`OnErrorPolicy::Abort`]. Abort, + /// Step timed out while the remaining global deadline was exhausted. GlobalDeadlineFallback, } +/// Step failure category shared by external and in-process execution paths. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum StepFailureKind { + /// Failed to serialize the input [`MuninnEnvelopeV1`] for step stdin. SerializeInput, + /// Child process could not be spawned. SpawnFailed, + /// Stdin/stdout/stderr I/O or wait failed before a definitive exit status. IoFailed, + /// Step exceeded its effective timeout budget. Timeout, + /// Child exited non-zero after successful I/O. NonZeroExit, + /// Step stdout was missing, truncated, or not valid for the configured I/O mode. InvalidStdout, + /// Envelope-json stdout parsed as JSON but failed [`MuninnEnvelopeV1`] validation. InvalidEnvelope, } diff --git a/src/runner/codec.rs b/src/runner/codec.rs index 6bce7c4..93535c3 100644 --- a/src/runner/codec.rs +++ b/src/runner/codec.rs @@ -1,32 +1,48 @@ +//! Step stdin/stdout codec for [`MuninnEnvelopeV1`] pipeline I/O modes. +//! +//! [`StepIoMode::EnvelopeJson`] round-trips full envelopes; [`StepIoMode::TextFilter`] +//! (and [`StepIoMode::Auto`], resolved to text filter) reads and writes plain text +//! against `output.final_text` or `transcript.raw_text`. Used by [`super::execution`]. + use crate::config::{PipelineStepConfig, StepIoMode}; use crate::envelope::MuninnEnvelopeV1; use serde_json::Value; +/// How decoded step stdout should be reflected in pipeline trace policy. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum DecodeDisposition { + /// Step output replaced or updated the envelope normally. Normal, + /// Non-strict mode preserved the input envelope after invalid stdout. ContractBypass, } +/// Decoded step stdout paired with contract disposition metadata. #[derive(Debug)] pub(super) struct DecodedStepOutput { pub(super) envelope: MuninnEnvelopeV1, pub(super) disposition: DecodeDisposition, } +/// Codec failure category mapped to [`super::StepFailureKind`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum CodecErrorKind { + /// Input envelope serialization failed. SerializeInput, + /// Stdout was not valid for the configured I/O mode. InvalidStdout, + /// Envelope-json stdout failed [`MuninnEnvelopeV1`] deserialization. InvalidEnvelope, } +/// Codec-layer error with a caller-facing message. #[derive(Debug)] pub(super) struct CodecError { pub(super) kind: CodecErrorKind, pub(super) message: String, } +/// Serialize `input_envelope` for step stdin according to `step.io_mode`. pub(super) fn encode_step_input( step: &PipelineStepConfig, input_envelope: &MuninnEnvelopeV1, @@ -43,6 +59,11 @@ pub(super) fn encode_step_input( } } +/// Parse step stdout into the next envelope for the configured I/O mode. +/// +/// When `strict_step_contract` is false, invalid envelope-json stdout returns +/// the input envelope with [`DecodeDisposition::ContractBypass`] instead of +/// failing. pub(super) fn decode_step_output( step: &PipelineStepConfig, input_envelope: MuninnEnvelopeV1, diff --git a/src/runner/execution.rs b/src/runner/execution.rs index 62c6bb5..0fbcb21 100644 --- a/src/runner/execution.rs +++ b/src/runner/execution.rs @@ -1,3 +1,9 @@ +//! External subprocess execution glue for one pipeline step. +//! +//! Encodes stdin via [`super::codec`], runs the child through [`super::transport`], +//! maps transport and codec failures onto [`super::StepFailure`], and records +//! contract-bypass policy when non-strict decoding preserves the input envelope. + use std::time::Duration; use crate::config::PipelineStepConfig; @@ -7,6 +13,12 @@ use super::codec::{self, CodecError, CodecErrorKind, DecodeDisposition}; use super::transport::{self, CapturedOutput, CommandError, CommandErrorKind}; use super::{PipelinePolicyApplied, StepFailure, StepFailureKind, StepSuccess}; +/// Run `step` as an external command with encoded stdin and decoded stdout. +/// +/// Non-zero exit codes, stdout truncation past `max_stdout_bytes`, and codec +/// errors become [`super::StepFailure`]. Successful runs return +/// [`super::StepSuccess`] with stderr rendered from capped bytes plus +/// `truncation_suffix` when truncated. pub(super) async fn run_external_step( step: &PipelineStepConfig, input_envelope: MuninnEnvelopeV1, diff --git a/src/runner/transport.rs b/src/runner/transport.rs index 21404ec..a8898ba 100644 --- a/src/runner/transport.rs +++ b/src/runner/transport.rs @@ -1,3 +1,9 @@ +//! Async subprocess transport for external pipeline steps. +//! +//! Spawns a child with piped stdin/stdout/stderr, writes step input, and +//! collects capped output streams concurrently so large stdin payloads cannot +//! deadlock against unread stdout. Used by [`super::execution`]. + use std::io; use std::process::Stdio; use std::time::Duration; @@ -5,8 +11,12 @@ use std::time::Duration; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::process::Command; use tokio::task::JoinHandle; -use tokio::time::timeout; +use tokio::time::{timeout, Instant}; + +const COMMAND_FAILURE_CLEANUP_MIN: Duration = Duration::from_millis(50); +const COMMAND_FAILURE_CLEANUP_MAX: Duration = Duration::from_millis(500); +/// Successful child process completion with capped stream captures. #[derive(Debug)] pub(super) struct CommandResult { pub(super) stdout: CapturedOutput, @@ -15,26 +25,46 @@ pub(super) struct CommandResult { pub(super) success: bool, } +/// Byte capture from a child stream with truncation metadata. #[derive(Debug, Default)] pub(super) struct CapturedOutput { pub(super) bytes: Vec, + /// True when additional bytes were read but discarded past the capture limit. pub(super) truncated: bool, } +/// Failure phase while spawning, writing stdin, waiting, or reading streams. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum CommandErrorKind { + /// `Command::spawn` failed. Spawn, + /// Child stdin pipe was unavailable after spawn. MissingStdin, + /// Child stdout pipe was unavailable after spawn. MissingStdout, + /// Child stderr pipe was unavailable after spawn. MissingStderr, + /// Writing step input to stdin failed. WriteStdin, + /// Shutting down stdin after the write failed. CloseStdin, + /// Waiting for child exit failed. Wait, + /// Draining the stdout reader task failed. ReadStdout, + /// Draining the stderr reader task failed. ReadStderr, + /// The overall command exceeded `timeout_budget`. Timeout, } +enum WritePhaseError { + Write(io::Error), + Close(io::Error), + Wait(io::Error), +} + +/// Transport-layer failure with optional partial stderr capture. #[derive(Debug)] pub(super) struct CommandError { pub(super) kind: CommandErrorKind, @@ -44,6 +74,11 @@ pub(super) struct CommandError { pub(super) stderr: CapturedOutput, } +/// Spawn `cmd` with `args`, write `stdin_bytes`, and collect capped stdout/stderr. +/// +/// The child is killed on drop and again during timeout or I/O failure cleanup. +/// Returns [`CommandErrorKind::Timeout`] when `timeout_budget` elapses before +/// stdin is fully written and the process exits. pub(super) async fn run_command( cmd: &str, args: &[String], @@ -76,26 +111,6 @@ pub(super) async fn run_command( stderr: CapturedOutput::default(), })?; - stdin - .write_all(stdin_bytes) - .await - .map_err(|source| CommandError { - kind: CommandErrorKind::WriteStdin, - details: source.to_string(), - timed_out: false, - exit_status: None, - stderr: CapturedOutput::default(), - })?; - - stdin.shutdown().await.map_err(|source| CommandError { - kind: CommandErrorKind::CloseStdin, - details: source.to_string(), - timed_out: false, - exit_status: None, - stderr: CapturedOutput::default(), - })?; - drop(stdin); - let stdout = child.stdout.take().ok_or_else(|| CommandError { kind: CommandErrorKind::MissingStdout, details: String::new(), @@ -117,11 +132,59 @@ pub(super) async fn run_command( let stderr_reader = tokio::spawn(async move { read_to_end_capped(stderr, max_stderr_bytes).await }); - let status = match timeout(timeout_budget, child.wait()).await { + let write_and_wait = async { + stdin + .write_all(stdin_bytes) + .await + .map_err(WritePhaseError::Write)?; + stdin.shutdown().await.map_err(WritePhaseError::Close)?; + drop(stdin); + child.wait().await.map_err(WritePhaseError::Wait) + }; + + let wait_result = timeout(timeout_budget, write_and_wait).await; + let status = match wait_result { Ok(Ok(status)) => status, - Ok(Err(source)) => { - let (stderr, cleanup_notes) = - cleanup_after_command_failure(&mut child, stdout_reader, stderr_reader).await; + Ok(Err(WritePhaseError::Write(source))) => { + let (stderr, cleanup_notes) = cleanup_after_command_failure_bounded( + &mut child, + stdout_reader, + stderr_reader, + timeout_budget, + ) + .await; + return Err(CommandError { + kind: CommandErrorKind::WriteStdin, + details: format_command_error_details(source.to_string(), cleanup_notes), + timed_out: false, + exit_status: None, + stderr, + }); + } + Ok(Err(WritePhaseError::Close(source))) => { + let (stderr, cleanup_notes) = cleanup_after_command_failure_bounded( + &mut child, + stdout_reader, + stderr_reader, + timeout_budget, + ) + .await; + return Err(CommandError { + kind: CommandErrorKind::CloseStdin, + details: format_command_error_details(source.to_string(), cleanup_notes), + timed_out: false, + exit_status: None, + stderr, + }); + } + Ok(Err(WritePhaseError::Wait(source))) => { + let (stderr, cleanup_notes) = cleanup_after_command_failure_bounded( + &mut child, + stdout_reader, + stderr_reader, + timeout_budget, + ) + .await; return Err(CommandError { kind: CommandErrorKind::Wait, details: format_command_error_details(source.to_string(), cleanup_notes), @@ -131,14 +194,16 @@ pub(super) async fn run_command( }); } Err(_) => { - let (stderr, cleanup_notes) = - cleanup_after_command_failure(&mut child, stdout_reader, stderr_reader).await; + let (stderr, _cleanup_notes) = cleanup_after_command_failure_bounded( + &mut child, + stdout_reader, + stderr_reader, + timeout_budget, + ) + .await; return Err(CommandError { kind: CommandErrorKind::Timeout, - details: format_command_error_details( - timeout_budget.as_millis().to_string(), - cleanup_notes, - ), + details: timeout_budget.as_millis().to_string(), timed_out: true, exit_status: None, stderr, @@ -217,34 +282,108 @@ async fn drain_reader( } } -async fn cleanup_after_command_failure( +async fn cleanup_after_command_failure_bounded( child: &mut tokio::process::Child, - stdout_reader: JoinHandle>, - stderr_reader: JoinHandle>, + mut stdout_reader: JoinHandle>, + mut stderr_reader: JoinHandle>, + timeout_budget: Duration, ) -> (CapturedOutput, Vec) { + let cleanup_timeout = cleanup_timeout_for_budget(timeout_budget); + let cleanup_deadline = Instant::now() + cleanup_timeout; let mut cleanup_notes = Vec::new(); - if let Err(source) = child.kill().await { + if let Err(source) = child.start_kill() { cleanup_notes.push(format!("child kill during cleanup failed: {source}")); } - if let Err(source) = child.wait().await { - cleanup_notes.push(format!("child wait during cleanup failed: {source}")); + match remaining_cleanup_budget(cleanup_deadline) { + Some(remaining) => match timeout(remaining, child.wait()).await { + Ok(Ok(_)) => {} + Ok(Err(source)) => { + cleanup_notes.push(format!("child wait during cleanup failed: {source}")); + } + Err(_) => cleanup_notes.push(format!( + "child wait during cleanup exceeded {}ms", + cleanup_timeout.as_millis() + )), + }, + None => cleanup_notes.push(format!( + "child wait during cleanup exceeded {}ms", + cleanup_timeout.as_millis() + )), } - if let Err(source) = drain_reader(stdout_reader).await { - cleanup_notes.push(format!("stdout drain during cleanup failed: {source}")); - } + let stdout_drain = + drain_reader_until_cleanup_deadline(&mut stdout_reader, cleanup_deadline, "stdout").await; + cleanup_notes.extend(stdout_drain.notes); - let stderr = match drain_reader(stderr_reader).await { - Ok(stderr) => stderr, - Err(source) => { - cleanup_notes.push(format!("stderr drain during cleanup failed: {source}")); - CapturedOutput::default() - } + let stderr = + drain_reader_until_cleanup_deadline(&mut stderr_reader, cleanup_deadline, "stderr").await; + cleanup_notes.extend(stderr.notes); + + (stderr.output.unwrap_or_default(), cleanup_notes) +} + +struct BoundedDrain { + output: Option, + notes: Vec, +} + +async fn drain_reader_until_cleanup_deadline( + handle: &mut JoinHandle>, + cleanup_deadline: Instant, + stream_name: &'static str, +) -> BoundedDrain { + let Some(remaining) = remaining_cleanup_budget(cleanup_deadline) else { + handle.abort(); + return BoundedDrain { + output: None, + notes: vec![format!( + "{stream_name} drain during cleanup exceeded deadline" + )], + }; }; - (stderr, cleanup_notes) + match timeout(remaining, &mut *handle).await { + Ok(Ok(Ok(output))) => BoundedDrain { + output: Some(output), + notes: Vec::new(), + }, + Ok(Ok(Err(source))) => BoundedDrain { + output: None, + notes: vec![format!( + "{stream_name} drain during cleanup failed: {source}" + )], + }, + Ok(Err(source)) => BoundedDrain { + output: None, + notes: vec![format!( + "{stream_name} collection task during cleanup failed: {source}" + )], + }, + Err(_) => { + handle.abort(); + BoundedDrain { + output: None, + notes: vec![format!( + "{stream_name} drain during cleanup exceeded {}ms", + remaining.as_millis() + )], + } + } + } +} + +fn cleanup_timeout_for_budget(timeout_budget: Duration) -> Duration { + std::cmp::max( + COMMAND_FAILURE_CLEANUP_MIN, + std::cmp::min(timeout_budget, COMMAND_FAILURE_CLEANUP_MAX), + ) +} + +fn remaining_cleanup_budget(cleanup_deadline: Instant) -> Option { + let now = Instant::now(); + (cleanup_deadline > now).then(|| cleanup_deadline.duration_since(now)) } fn format_command_error_details(primary: String, cleanup_notes: Vec) -> String { @@ -254,3 +393,73 @@ fn format_command_error_details(primary: String, cleanup_notes: Vec) -> format!("{primary}; {}", cleanup_notes.join("; ")) } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + const LARGE_PAYLOAD_BYTES: usize = 512 * 1024; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn drains_stdout_while_writing_large_stdin_without_deadlock() { + let payload = vec![b'x'; LARGE_PAYLOAD_BYTES]; + + let result = run_command( + "/bin/cat", + &[], + &payload, + Duration::from_secs(10), + LARGE_PAYLOAD_BYTES * 2, + 1024, + ) + .await + .expect("cat should round-trip a large payload without deadlocking"); + + assert!(result.success); + assert_eq!(result.stdout.bytes.len(), LARGE_PAYLOAD_BYTES); + assert!(!result.stdout.truncated); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn times_out_when_child_never_reads_stdin() { + let payload = vec![b'x'; LARGE_PAYLOAD_BYTES]; + + let error = run_command( + "/bin/sh", + &["-c".to_string(), "sleep 5".to_string()], + &payload, + Duration::from_millis(200), + 1024, + 1024, + ) + .await + .expect_err("a child that never drains stdin must hit the timeout"); + + assert!(error.timed_out); + assert_eq!(error.kind, CommandErrorKind::Timeout); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn timeout_cleanup_is_bounded_when_descendant_keeps_pipes_open() { + let payload = vec![b'x'; LARGE_PAYLOAD_BYTES]; + + let result = timeout( + Duration::from_secs(1), + run_command( + "/bin/sh", + &["-c".to_string(), "sleep 5 & sleep 5".to_string()], + &payload, + Duration::from_millis(100), + 1024, + 1024, + ), + ) + .await + .expect("run_command cleanup should not wait for the descendant sleep"); + let error = result.expect_err("a child that never drains stdin must hit the timeout"); + + assert!(error.timed_out); + assert_eq!(error.kind, CommandErrorKind::Timeout); + assert_eq!(error.details, "100"); + } +} diff --git a/src/runtime_flow.rs b/src/runtime_flow.rs index e7ef19f..256212d 100644 --- a/src/runtime_flow.rs +++ b/src/runtime_flow.rs @@ -1,3 +1,9 @@ +//! Recording and injection state machine coordinator. +//! +//! Encapsulates [`AppState`] transitions with the indicator, audio recorder, and +//! text injector adapters. Called from the runtime worker Tokio runtime; does not +//! touch the tao event loop directly. + use std::time::Duration; use crate::{ @@ -7,6 +13,7 @@ use crate::{ }; use tracing::warn; +/// Coordinates recording capture, indicator updates, and text injection. #[derive(Debug)] pub struct RuntimeFlowCoordinator where @@ -26,6 +33,7 @@ where R: AudioRecorder, T: TextInjector, { + /// Create a coordinator starting in [`AppState::Idle`]. pub fn new(indicator: I, recorder: R, injector: T) -> Self { Self { state: AppState::Idle, @@ -35,38 +43,47 @@ where } } + /// Current high-level runtime state. pub fn state(&self) -> AppState { self.state } + /// Mutable access for callers that apply transitions outside this type. pub fn state_mut(&mut self) -> &mut AppState { &mut self.state } + /// Mutable indicator adapter handle. pub fn indicator_mut(&mut self) -> &mut I { &mut self.indicator } + /// Mutable recorder adapter handle. pub fn recorder_mut(&mut self) -> &mut R { &mut self.recorder } + /// Text injector used after pipeline routing. pub fn injector(&self) -> &T { &self.injector } + /// Borrow state, indicator, and injector together for post-recording processing. pub fn processing_parts(&mut self) -> (&mut AppState, &mut I, &T) { (&mut self.state, &mut self.indicator, &self.injector) } + /// Initialize the indicator to idle appearance. pub async fn initialize(&mut self) -> MacosAdapterResult<()> { self.indicator.initialize().await } + /// Begin push-to-talk recording. No-op when the state machine rejects [`AppEvent::PttPressed`]. pub async fn start_push_to_talk(&mut self, glyph: Option) -> MacosAdapterResult { self.start_push_to_talk_with_audio_sink(glyph, None).await } + /// Begin push-to-talk recording with an optional live audio frame sink. pub async fn start_push_to_talk_with_audio_sink( &mut self, glyph: Option, @@ -76,10 +93,12 @@ where .await } + /// Begin done-mode recording. No-op when the state machine rejects [`AppEvent::DoneTogglePressed`]. pub async fn start_done_mode(&mut self, glyph: Option) -> MacosAdapterResult { self.start_done_mode_with_audio_sink(glyph, None).await } + /// Begin done-mode recording with an optional live audio frame sink. pub async fn start_done_mode_with_audio_sink( &mut self, glyph: Option, @@ -94,6 +113,9 @@ where .await } + /// Stop push-to-talk capture and enter processing with the given initial indicator. + /// + /// Returns `None` when release is ignored. Resets to idle if stop fails. pub async fn finish_push_to_talk_for_processing( &mut self, initial_indicator: IndicatorState, @@ -103,6 +125,9 @@ where .await } + /// Stop done-mode capture and enter processing with the given initial indicator. + /// + /// Returns `None` when the toggle is ignored. Resets to idle if stop fails. pub async fn finish_done_mode_for_processing( &mut self, initial_indicator: IndicatorState, @@ -112,6 +137,9 @@ where .await } + /// Discard the active recording and flash the cancelled indicator briefly. + /// + /// No-op when cancel is not valid for the current state. pub async fn cancel_current_capture( &mut self, glyph: Option, @@ -137,6 +165,10 @@ where Ok(true) } + /// Inject routed text after processing and drive output indicator timing. + /// + /// No-op unless the coordinator is in [`AppState::Processing`]. Resets the + /// indicator to idle after injection failures. pub async fn complete_processing_with_route( &mut self, route: &InjectionRoute, @@ -259,6 +291,9 @@ where } } +/// Map a macOS hotkey edge to the corresponding [`AppEvent`]. +/// +/// Release events for toggle and cancel chords are ignored. pub fn map_hotkey_event(event: HotkeyEvent) -> Option { match (event.action, event.kind) { (HotkeyAction::PushToTalk, HotkeyEventKind::Pressed) => Some(AppEvent::PttPressed), diff --git a/src/runtime_permissions.rs b/src/runtime_permissions.rs index b0e30c6..70b572f 100644 --- a/src/runtime_permissions.rs +++ b/src/runtime_permissions.rs @@ -1,3 +1,10 @@ +//! macOS TCC permission refresh before recording and injection. +//! +//! Hotkey recording requires Input Monitoring; tray and external controls use a +//! lighter recording preflight. Recording prompts abort the active start gesture +//! so the user retries with stable permissions; injection proceeds immediately +//! when Accessibility is granted during its refresh. + use anyhow::{anyhow, Result}; use muninn::{PermissionKind, PermissionPreflightStatus, PermissionStatus, PermissionsAdapter}; use tracing::{info, warn}; @@ -14,20 +21,29 @@ where .map_err(|error| anyhow!(error)) } +/// Outcome of a recording permission refresh triggered by a user gesture. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct RecordingPermissionRefresh { + /// Permission snapshot after any prompts completed. pub(crate) preflight: PermissionPreflightStatus, + /// Whether a microphone access prompt was shown during this refresh. pub(crate) requested_microphone: bool, + /// Whether an Input Monitoring prompt was shown during this refresh. pub(crate) requested_input_monitoring: bool, } +/// Surface that initiated a recording start request. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RecordingStartSource { + /// Global hotkey listener; requires Input Monitoring when not already granted. Hotkey, + /// Menu-bar tray click; does not bootstrap Input Monitoring on first use. Tray, + /// MCP or `muninn://` external control action. External, } +/// Stable log label for a [`RecordingStartSource`]. pub(crate) fn recording_source_name(source: RecordingStartSource) -> &'static str { match source { RecordingStartSource::Hotkey => "hotkey", @@ -36,6 +52,10 @@ pub(crate) fn recording_source_name(source: RecordingStartSource) -> &'static st } } +/// Prompt for missing recording permissions appropriate to the start source. +/// +/// Microphone is requested for all sources. Input Monitoring prompts run only +/// for hotkey starts. pub(crate) async fn refresh_recording_permissions_for_user_action( permissions: &A, source: RecordingStartSource, @@ -86,12 +106,16 @@ where }) } +/// Outcome of an injection permission refresh triggered before text output. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct InjectionPermissionRefresh { + /// Permission snapshot after any prompts completed. pub(crate) preflight: PermissionPreflightStatus, + /// Whether an Accessibility prompt was shown during this refresh. pub(crate) requested_accessibility: bool, } +/// Prompt for Accessibility access when injection is about to run. pub(crate) async fn refresh_injection_permissions_for_user_action( permissions: &A, ) -> Result @@ -124,6 +148,12 @@ where }) } +/// Whether to cancel this recording start after a permission refresh. +/// +/// Aborts when required permissions are still missing, or when a prompt fired +/// during this gesture so the user can retry with stable TCC state. Input +/// Monitoring is only prompted for hotkey starts, and hotkey Input Monitoring +/// prompts always abort. pub(crate) fn should_abort_recording_start( preflight: PermissionPreflightStatus, requested_microphone: bool, @@ -195,19 +225,24 @@ fn log_recording_start_block( ); } +/// Whether to skip injection after a permission refresh. +/// +/// Proceeds when Accessibility is granted, including immediately after a +/// successful prompt. Aborts when injection permissions remain blocked. pub(crate) fn should_abort_injection( preflight: PermissionPreflightStatus, requested_accessibility: bool, ) -> bool { match ensure_injection_allowed(preflight) { - Ok(()) if requested_accessibility => { - info!( - target: logging::TARGET_RUNTIME, - "Accessibility access changed during this interaction; retry the injection action" - ); - true + Ok(()) => { + if requested_accessibility { + info!( + target: logging::TARGET_RUNTIME, + "Accessibility access was granted during this interaction; proceeding with injection" + ); + } + false } - Ok(()) => false, Err(error) => { log_injection_block(preflight, &error); true @@ -238,6 +273,7 @@ fn log_injection_block(preflight: PermissionPreflightStatus, error: &anyhow::Err ); } +/// Validate that recording may start for the given source and preflight snapshot. pub(crate) fn ensure_recording_can_start( preflight: PermissionPreflightStatus, source: RecordingStartSource, diff --git a/src/runtime_pipeline.rs b/src/runtime_pipeline.rs index b5754c8..90c4c66 100644 --- a/src/runtime_pipeline.rs +++ b/src/runtime_pipeline.rs @@ -1,3 +1,9 @@ +//! Post-recording pipeline orchestration and live config reload. +//! +//! Builds the [`PipelineRunner`], maps indicator stages to transcription versus +//! refine steps, and performs injection after [`Orchestrator::route_injection`]. +//! Runs on the runtime worker thread. + use std::path::Path; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -19,15 +25,26 @@ use uuid::Uuid; use crate::replay_dispatch::ReplayPersistenceService; use crate::{internal_tools, logging}; +/// Inputs required to run the pipeline and inject routed text for one utterance. pub(crate) struct ProcessingContext<'a> { + /// Profile, voice, and effective config resolved at capture time. pub(crate) resolved: &'a ResolvedUtteranceConfig, + /// Pipeline steps and deadline for this utterance. pub(crate) pipeline: &'a PipelineConfig, + /// Step executor bound to builtin in-process tools. pub(crate) runner: &'a PipelineRunner, + /// macOS accessibility text injector. pub(crate) injector: &'a MacosTextInjector, + /// Background replay writer that may take ownership of the temp WAV. pub(crate) replay_persist: &'a ReplayPersistenceService, + /// Optional streaming STT outcome merged into the envelope before the pipeline runs. pub(crate) streaming_outcome: Option, } +/// Run the pipeline, persist replay, inject text, and return to idle. +/// +/// Refreshes Accessibility permission before injection. Deletes the temporary WAV +/// unless replay persistence queued successfully. pub(crate) async fn process_and_inject( context: ProcessingContext<'_>, indicator: &mut I, @@ -45,6 +62,7 @@ where replay_persist, streaming_outcome, } = context; + let mut worker_owns_wav = false; let result = async { let envelope = build_envelope(resolved, recorded, streaming_outcome); let mut outcome = run_pipeline_with_indicator_stages( @@ -73,7 +91,8 @@ where let missing_credentials_feedback = should_show_missing_credentials_feedback(&outcome); let permissions = MacosPermissionsAdapter::new(); - replay_persist.enqueue(replay_config, envelope, outcome, route, replay_recorded); + worker_owns_wav = + replay_persist.enqueue(replay_config, envelope, outcome, route, replay_recorded); *state = state.on_event(AppEvent::ProcessingFinished); @@ -141,7 +160,9 @@ where .await; *state = state.on_event(AppEvent::InjectionFinished); - cleanup_recording_file(&recorded.wav_path); + if !worker_owns_wav { + cleanup_recording_file(&recorded.wav_path); + } result?; let indicator_state = indicator .state() @@ -159,6 +180,7 @@ where Ok(()) } +/// Indicator shown immediately after recording stops, before the pipeline runs. pub(crate) fn initial_processing_indicator(pipeline: &PipelineConfig) -> IndicatorState { match pipeline.steps.first() { Some(step) if internal_tools::is_transcription_step(step) => IndicatorState::Transcribing, @@ -166,6 +188,7 @@ pub(crate) fn initial_processing_indicator(pipeline: &PipelineConfig) -> Indicat } } +/// Clone configured pipeline steps and resolve builtin or sibling-binary commands. pub(crate) fn resolve_pipeline_config(config: &AppConfig) -> Result { let mut pipeline = config.pipeline.clone(); for step in &mut pipeline.steps { @@ -177,6 +200,7 @@ pub(crate) fn resolve_pipeline_config(config: &AppConfig) -> Result bool { outcome_contains_missing_credential_error(outcome) || outcome_trace_contains_missing_credential_error(outcome) @@ -545,7 +574,8 @@ fn resolve_step_command(cmd: &str) -> Result { Ok(cmd.to_string()) } -fn cleanup_recording_file(path: &Path) { +/// Best-effort delete of a temporary capture WAV file. +pub(crate) fn cleanup_recording_file(path: &Path) { if let Err(error) = std::fs::remove_file(path) { warn!( target: logging::TARGET_RECORDING, diff --git a/src/runtime_shell.rs b/src/runtime_shell.rs index e4c511d..ca1e470 100644 --- a/src/runtime_shell.rs +++ b/src/runtime_shell.rs @@ -1,4 +1,17 @@ -use std::path::PathBuf; +//! tao event-loop shell for the Muninn menu-bar runtime. +//! +//! Owns the main-thread [`tao`] loop, tray icon lifecycle, and forwarding of +//! [`UserEvent`] messages into the background [`RuntimeWorker`]. On macOS the +//! process runs as an accessory app (no dock icon). URL-scheme and MCP handlers +//! register on this thread *before* the loop starts. + +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; use anyhow::{Context, Result}; use muninn::{ @@ -21,6 +34,7 @@ use crate::{config_watch, logging}; const CONFIG_RELOAD_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(50); +/// Loaded configuration and startup permission snapshot for the menu-bar runtime. pub(crate) struct AppRuntime { config_path: PathBuf, config: AppConfig, @@ -29,6 +43,10 @@ pub(crate) struct AppRuntime { } impl AppRuntime { + /// Validate platform support, run macOS permission preflight, and build runtime state. + /// + /// Uses a short-lived current-thread Tokio runtime on the calling thread so + /// permission adapters can run before the tao event loop starts. pub(crate) fn new(config_path: PathBuf, config: AppConfig) -> Result { let platform = detect_platform(); ensure_supported_platform().with_context(|| { @@ -51,6 +69,11 @@ impl AppRuntime { }) } + /// Run the main-thread tao event loop until the process exits. + /// + /// Spawns the [`RuntimeWorker`], config watchers, and optional MCP server on + /// `StartCause::Init`. Tray, indicator, and config-reload [`UserEvent`] + /// updates are handled here; recording state lives on the worker thread. pub(crate) fn run(self) -> Result<()> { let mut event_loop_builder = EventLoopBuilder::::with_user_event(); let mut event_loop = event_loop_builder.build(); @@ -69,7 +92,10 @@ impl AppRuntime { // applicationWillFinishLaunching, earlier than StartCause::Init, so the // observer set up here must already be in place to catch cold-launch URLs. #[cfg(target_os = "macos")] - if self.config.external_control.url_scheme_enabled { + { + crate::external_control::set_url_scheme_enabled( + self.config.external_control.url_scheme_enabled, + ); crate::external_control::install_url_scheme_handler(proxy.clone()); } @@ -97,6 +123,9 @@ impl AppRuntime { let mut last_indicator_state = IndicatorState::Idle; let mut last_active_glyph = None; let runtime_status = crate::external_control::RuntimeStatusHandle::new(preflight); + let mcp_start_recording_enabled = Arc::new(AtomicBool::new( + current_config.external_control.start_recording_enabled, + )); event_loop.run(move |event, _, control_flow| { *control_flow = ControlFlow::Wait; @@ -137,7 +166,7 @@ impl AppRuntime { proxy.clone(), current_config.external_control.mcp_bind_address.clone(), runtime_status.clone(), - current_config.external_control.start_recording_enabled, + mcp_start_recording_enabled.clone(), ); } } @@ -215,6 +244,14 @@ impl AppRuntime { } Event::UserEvent(UserEvent::ConfigReloaded(config)) => { current_config = (*config).clone(); + mcp_start_recording_enabled.store( + current_config.external_control.start_recording_enabled, + Ordering::SeqCst, + ); + #[cfg(target_os = "macos")] + crate::external_control::set_url_scheme_enabled( + current_config.external_control.url_scheme_enabled, + ); indicator_config = current_config.indicator.clone(); preview_selection = current_config.resolve_profile_selection(&preview_context); crate::sync_os_autostart(&config_path, ¤t_config); diff --git a/src/runtime_tray.rs b/src/runtime_tray.rs index 8cb4af1..77a37d8 100644 --- a/src/runtime_tray.rs +++ b/src/runtime_tray.rs @@ -1,3 +1,10 @@ +//! Menu-bar tray icon and main-thread indicator bridge. +//! +//! [`TrayIconEvent`] callbacks arrive on an arbitrary thread; the bridge +//! re-posts them as [`UserEvent::TrayEvent`] on the tao loop. +//! [`EventLoopIndicator`] implements [`IndicatorAdapter`] by caching state on the +//! worker thread and forwarding appearance updates to the main thread. + use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -12,27 +19,38 @@ use tray_icon::{Icon, MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEv use crate::external_control::ExternalControlAction; use crate::logging; +/// Custom tao user events handled on the main thread. #[derive(Debug, Clone)] pub(crate) enum UserEvent { + /// Tray icon click or other menu-bar interaction. TrayEvent(TrayIconEvent), + /// Indicator glyph or color changed; refresh the tray icon. IndicatorUpdated { state: IndicatorState, glyph: Option, }, + /// Frontmost-app context changed; refresh idle preview glyph. PreviewContextUpdated(TargetContextSnapshot), + /// Config file reload succeeded; shell applies tray/MCP settings locally. ConfigReloaded(Box), + /// Config file reload failed; previous config remains active. ConfigReloadFailed(String), + /// Retry delivering a config reload that filled the runtime message queue. RetryPendingConfigReload, + /// Runtime worker thread exited with an error. RuntimeFailure(String), + /// MCP or `muninn://` URL scheme action forwarded from external control. ExternalControl(ExternalControlAction), } +/// Forward [`TrayIconEvent`] callbacks onto the tao event loop as [`UserEvent`]. pub(crate) fn install_tray_event_bridge(proxy: EventLoopProxy) { TrayIconEvent::set_event_handler(Some(move |event| { send_user_event(&proxy, UserEvent::TrayEvent(event), "tray_event_bridge"); })); } +/// Post a [`UserEvent`] to the main thread; returns false when the loop is gone. pub(crate) fn send_user_event( proxy: &EventLoopProxy, event: UserEvent, @@ -47,6 +65,7 @@ pub(crate) fn send_user_event( } } +/// Build the Muninn menu-bar tray icon with the initial indicator image. pub(crate) fn build_tray_icon(icon: Icon) -> Result { TrayIconBuilder::new() .with_icon(icon) @@ -55,6 +74,10 @@ pub(crate) fn build_tray_icon(icon: Icon) -> Result { .context("creating menu bar tray icon") } +/// Refresh tray icon and tooltip for the current indicator and preview glyphs. +/// +/// Honors `indicator.show_recording` and `indicator.show_processing` by +/// collapsing hidden busy states back to idle appearance. pub(crate) fn update_tray_appearance( tray_icon: &tray_icon::TrayIcon, state: IndicatorState, @@ -96,6 +119,7 @@ pub(crate) fn map_tray_event(event: &TrayIconEvent) -> Option, @@ -105,6 +129,7 @@ pub(crate) struct EventLoopIndicator { } impl EventLoopIndicator { + /// Create an indicator bridge bound to the given event-loop proxy. pub(crate) fn new(proxy: EventLoopProxy) -> Self { Self { proxy, @@ -264,6 +289,7 @@ impl IndicatorAdapter for EventLoopIndicator { } } +/// Build a menu-bar RGBA icon for the given indicator state and glyphs. pub(crate) fn indicator_icon( state: IndicatorState, active_glyph: Option, @@ -323,6 +349,10 @@ pub(crate) fn indicator_icon( .context("building indicator icon") } +/// Resolve which glyph to paint for an indicator state. +/// +/// Idle prefers the contextual preview glyph; busy states prefer the frozen +/// utterance glyph. [`IndicatorState::MissingCredentials`] always uses `?`. pub(crate) fn resolved_indicator_glyph( state: IndicatorState, active_glyph: Option, @@ -410,9 +440,12 @@ fn menu_bar_icon_rgba( rgba } +/// Pixel glyph drawn inside the circular menu-bar indicator. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum IndicatorGlyph { + /// Voice or default letter glyph for the current state. Letter(char), + /// Reserved `?` glyph for missing provider credentials. Question, } diff --git a/src/runtime_worker.rs b/src/runtime_worker.rs index c0e4c2d..61a0082 100644 --- a/src/runtime_worker.rs +++ b/src/runtime_worker.rs @@ -1,3 +1,10 @@ +//! Background runtime worker for hotkeys, recording, and pipeline processing. +//! +//! Runs on a dedicated OS thread with a multi-threaded Tokio runtime. Receives +//! [`RuntimeMessage`] commands from the main-thread event loop and drives +//! [`RuntimeFlowCoordinator`] transitions, permission refresh, and +//! [`runtime_pipeline::process_and_inject`]. + use std::collections::HashMap; use std::sync::Arc; @@ -21,13 +28,21 @@ use crate::runtime_permissions::{ use crate::runtime_tray::{send_user_event, EventLoopIndicator, UserEvent}; use crate::{logging, runtime_pipeline, HOTKEY_RECOVERY_DELAY, OUTPUT_INDICATOR_MIN_DURATION}; +/// Commands forwarded from the main-thread event loop to the runtime worker. #[derive(Debug, Clone)] pub(crate) enum RuntimeMessage { + /// Tray left-click toggle mapped to [`ExternalControlAction`]. TrayControl(ExternalControlAction), + /// Live config reload; hotkey bindings stay frozen until process restart. ReloadConfig(Box), + /// MCP or `muninn://` URL scheme recording control. ExternalControl(ExternalControlAction), } +/// Spawn the runtime worker on a background OS thread. +/// +/// Failures to build or run the Tokio runtime post [`UserEvent::RuntimeFailure`] +/// back to the main thread so the tray can reset to idle. pub(crate) fn spawn_runtime_worker( config: AppConfig, preflight: PermissionPreflightStatus, diff --git a/src/scoring.rs b/src/scoring.rs index 776ed96..436342a 100644 --- a/src/scoring.rs +++ b/src/scoring.rs @@ -1,3 +1,10 @@ +//! Confidence-gated replacement of uncertain transcript spans. +//! +//! Reads `uncertain_spans` and scored `replacements` on a [`MuninnEnvelopeV1`], +//! applies top-score and margin thresholds (stricter for acronyms and short +//! spans), and materializes `output.final_text` when accepted edits change the +//! raw transcript. + use std::collections::HashMap; use crate::envelope::MuninnEnvelopeV1; @@ -26,12 +33,18 @@ struct AcceptedReplacement { to: String, } +/// Score and margin gates for accepting a replacement candidate. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] pub struct Thresholds { + /// Minimum top candidate score for normal-length spans. pub min_top_score: f32, + /// Minimum gap between first- and second-place scores for normal spans. pub min_margin: f32, + /// Minimum top score when the span is acronym-like or short. pub acronym_min_top_score: f32, + /// Minimum score margin for acronym-like or short spans. pub acronym_min_margin: f32, + /// Character length at or below which strict acronym thresholds apply. pub short_span_max_chars: usize, } @@ -71,6 +84,7 @@ impl From<&crate::config::ScoringConfig> for Thresholds { } } +/// Span shape inputs that select normal vs strict replacement thresholds. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub struct SpanMetadata { pub is_acronym: bool, @@ -78,6 +92,7 @@ pub struct SpanMetadata { } impl SpanMetadata { + /// Build metadata for a candidate span. #[must_use] pub const fn new(char_len: usize, is_acronym: bool) -> Self { Self { @@ -86,27 +101,35 @@ impl SpanMetadata { } } + /// True when strict acronym thresholds should apply. #[must_use] pub const fn is_acronym_or_short(self, short_span_max_chars: usize) -> bool { self.is_acronym || self.char_len <= short_span_max_chars } } +/// Inputs for evaluating whether a span's top replacement should be accepted. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ReplacementDecisionInput { pub candidate_scores: Vec, pub span: SpanMetadata, } +/// Why a replacement candidate was accepted or rejected. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum DecisionReason { + /// Top score fell below the applicable minimum threshold. BelowTopThreshold, + /// Top score passed but margin to the runner-up was too small. BelowMarginThreshold, + /// Top score and margin both satisfied the applicable thresholds. Accepted, + /// No finite candidate scores were provided. NoCandidates, } +/// Outcome of [`decide_replacement`] with diagnostic score fields. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] pub struct ReplacementDecision { pub accepted: bool, @@ -117,6 +140,7 @@ pub struct ReplacementDecision { pub used_strict_thresholds: bool, } +/// Evaluate candidate scores for one uncertain span against `thresholds`. #[must_use] pub fn decide_replacement( input: &ReplacementDecisionInput, @@ -203,6 +227,10 @@ fn top_two_scores(scores: &[f32]) -> (Option, Option) { (top, second) } +/// Apply scored replacements to a completed or fallback [`PipelineOutcome`]. +/// +/// Returns whether `output.final_text` was materialized. No-op for aborted +/// outcomes. pub fn apply_scored_replacements_to_outcome(outcome: &mut PipelineOutcome, thresholds: T) -> bool where T: Into, @@ -217,6 +245,10 @@ where } } +/// Materialize `output.final_text` from scored span replacements when gates pass. +/// +/// No-op when `output.final_text` is already non-empty, when raw text is missing, +/// or when no accepted replacements change the transcript. pub fn apply_scored_replacements_to_envelope( envelope: &mut MuninnEnvelopeV1, thresholds: &Thresholds, @@ -380,7 +412,7 @@ fn looks_like_acronym(text: &str) -> bool { } fn non_empty_text(text: &Option) -> Option<&str> { - text.as_deref().filter(|value| !value.is_empty()) + text.as_deref().filter(|value| !value.trim().is_empty()) } #[cfg(test)] diff --git a/src/secrets.rs b/src/secrets.rs index 2e43476..e8ab77b 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -1,7 +1,20 @@ +//! Secret resolution with environment-variable precedence over config. +//! +//! Whitespace-only values are treated as absent so empty strings do not +//! override a fallback source. + +/// Resolve a secret from an environment value, falling back to config. +/// +/// The environment value wins when present and non-empty after trimming. +/// Whitespace-only strings in either source are ignored. pub fn resolve_secret(env_value: Option, config_value: Option) -> Option { normalize_secret(env_value).or_else(|| normalize_secret(config_value)) } +/// Resolve a secret from `env_key`, falling back to config. +/// +/// Reads the named environment variable at call time and applies the same +/// precedence rules as [`resolve_secret`]. pub fn resolve_secret_from_env(env_key: &str, config_value: Option) -> Option { let env_value = std::env::var(env_key).ok(); resolve_secret(env_value, config_value) diff --git a/src/state.rs b/src/state.rs index ec926f0..0292194 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,23 +1,47 @@ +//! Recording-runtime state machine shared by hotkeys, tray, and external control. +//! +//! [`AppState::on_event`] is the single transition table. Invalid event/state +//! pairs leave the state unchanged; processing and injecting ignore new +//! recording triggers until the pipeline finishes. + +/// High-level capture and pipeline phase tracked by the runtime coordinator. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppState { + /// No active capture and no pipeline work in flight. Idle, + /// Push-to-talk capture; release ends recording and enters processing. RecordingPushToTalk, + /// Done-mode capture; a second done toggle ends recording and enters processing. RecordingDone, + /// Transcription and refinement running after capture ends. Processing, + /// Final text is being injected into the focused target. Injecting, } +/// Input events that drive [`AppState`] transitions from hotkeys, tray, and +/// external control. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppEvent { + /// Begin or continue push-to-talk capture. PttPressed, + /// End push-to-talk capture and start processing. PttReleased, + /// Toggle done-mode capture on or off. DoneTogglePressed, + /// Discard the active capture without running the pipeline. CancelPressed, + /// Pipeline finished; move to injection. ProcessingFinished, + /// Injection finished; return to idle. InjectionFinished, } impl AppState { + /// Apply an [`AppEvent`] and return the next state. + /// + /// Unhandled combinations are a no-op and return `self`. While processing + /// or injecting, recording triggers are ignored until the pipeline completes. #[must_use] pub const fn on_event(self, event: AppEvent) -> Self { use AppEvent::*; diff --git a/src/streaming_transcription.rs b/src/streaming_transcription.rs index 1bca85e..9f5f6ca 100644 --- a/src/streaming_transcription.rs +++ b/src/streaming_transcription.rs @@ -1,3 +1,12 @@ +//! Live transcription during capture via provider WebSocket or gRPC sessions. +//! +//! [`ActiveStreamingTranscription`] starts the first provider from +//! [`ResolvedUtteranceConfig::streaming_transcription_route`], forwards +//! [`AudioFrame`]s through a bounded channel, and maps finish/cancel outcomes +//! onto [`StreamingTranscriptOutcome`] for seeding the utterance envelope before +//! recorded transcription runs. Provider implementations live in +//! [`deepgram`], [`openai`], and [`google`]. + use std::sync::Arc; use std::time::Duration; @@ -19,6 +28,7 @@ pub mod openai; const STREAMING_FRAME_QUEUE_CAPACITY: usize = 64; +/// Final streaming transcription result merged into the utterance envelope. #[derive(Debug, Clone, PartialEq)] pub struct StreamingTranscriptOutcome { pub provider: TranscriptionProvider, @@ -28,6 +38,7 @@ pub struct StreamingTranscriptOutcome { } impl StreamingTranscriptOutcome { + /// Build a successful outcome with non-empty transcript text. #[must_use] pub fn produced(provider: TranscriptionProvider, raw_text: impl Into) -> Self { Self { @@ -43,6 +54,7 @@ impl StreamingTranscriptOutcome { } } + /// Build an empty-transcript outcome that records a diagnostic error entry. #[must_use] pub fn empty(provider: TranscriptionProvider, detail: impl Into) -> Self { let detail = detail.into(); @@ -64,6 +76,7 @@ impl StreamingTranscriptOutcome { } } + /// Map a streaming failure into an outcome with attempt and error payload. #[must_use] pub fn from_error(error: StreamingTranscriptionError) -> Self { let provider = error.provider(); @@ -75,6 +88,7 @@ impl StreamingTranscriptOutcome { } } + /// Write provider, trimmed `transcript.raw_text`, attempt, and errors onto `envelope`. pub fn apply_to_envelope(self, envelope: &mut MuninnEnvelopeV1) { envelope.transcript.provider = Some(self.provider.to_string()); if let Some(raw_text) = self @@ -96,8 +110,11 @@ impl StreamingTranscriptOutcome { } } +/// Failure or cancellation from a streaming provider session. #[derive(Debug, Clone, Error, PartialEq)] pub enum StreamingTranscriptionError { + /// Provider cannot run: missing credentials, unsupported recording config, or + /// unregistered implementation. Maps to the embedded `outcome` variant. #[error("{provider} streaming transcription unavailable: {detail}")] Unavailable { provider: TranscriptionProvider, @@ -105,17 +122,20 @@ pub enum StreamingTranscriptionError { code: String, detail: String, }, + /// Active session or transport failure after connect. #[error("{provider} streaming transcription failed: {detail}")] Failed { provider: TranscriptionProvider, code: String, detail: String, }, + /// Session stopped by [`ActiveStreamingTranscription::cancel`]. #[error("{provider} streaming transcription was cancelled")] Cancelled { provider: TranscriptionProvider }, } impl StreamingTranscriptionError { + /// Unavailable because the runtime or recording config cannot satisfy the provider. #[must_use] pub fn unavailable_runtime_capability( provider: TranscriptionProvider, @@ -130,6 +150,7 @@ impl StreamingTranscriptionError { } } + /// Unavailable because API keys or tokens are missing. #[must_use] pub fn unavailable_credentials( provider: TranscriptionProvider, @@ -144,6 +165,7 @@ impl StreamingTranscriptionError { } } + /// Active-session failure with provider-specific `code` and `detail`. #[must_use] pub fn failed( provider: TranscriptionProvider, @@ -157,11 +179,13 @@ impl StreamingTranscriptionError { } } + /// Cancellation sentinel for aborted streaming workers. #[must_use] pub const fn cancelled(provider: TranscriptionProvider) -> Self { Self::Cancelled { provider } } + /// Return the provider associated with this error. #[must_use] pub const fn provider(&self) -> TranscriptionProvider { match self { @@ -171,6 +195,7 @@ impl StreamingTranscriptionError { } } + /// Convert to a [`TranscriptionAttempt`] for envelope diagnostics. #[must_use] pub fn to_attempt(&self) -> TranscriptionAttempt { match self { @@ -199,6 +224,7 @@ impl StreamingTranscriptionError { } } + /// Serialize an envelope `errors` entry with `source = streaming_transcription`. #[must_use] pub fn to_error_value(&self) -> Value { match self { @@ -228,23 +254,30 @@ impl StreamingTranscriptionError { } } +/// Provider-specific streaming session that accepts audio and returns a final transcript. #[async_trait] pub trait StreamingTranscriptionSession: Send { + /// Send one PCM frame. No-op when `frame.samples` is empty. async fn send_audio(&mut self, frame: AudioFrame) -> Result<(), StreamingTranscriptionError>; + /// Flush the stream and return the accumulated final transcript. async fn finish( self: Box, ) -> Result; + /// Close the provider connection without requiring a transcript. async fn cancel(self: Box); } +/// Factory that opens a [`StreamingTranscriptionSession`] from resolved config. #[async_trait] pub trait StreamingTranscriptionProvider: Send + Sync { + /// Open a provider session using credentials and recording settings from `resolved`. async fn start( &self, resolved: &ResolvedUtteranceConfig, ) -> Result, StreamingTranscriptionError>; } +/// Registered [`TranscriptionProvider`] paired with its streaming implementation. #[derive(Clone)] pub struct StreamingTranscriptionProviderEntry { provider: TranscriptionProvider, @@ -252,6 +285,7 @@ pub struct StreamingTranscriptionProviderEntry { } impl StreamingTranscriptionProviderEntry { + /// Register a provider implementation for [`ActiveStreamingTranscription::start_with_providers`]. #[must_use] pub fn new

(provider: TranscriptionProvider, implementation: P) -> Self where @@ -263,18 +297,21 @@ impl StreamingTranscriptionProviderEntry { } } + /// Return the registered provider id. #[must_use] pub const fn provider(&self) -> TranscriptionProvider { self.provider } } +/// Handle for an active or failed streaming session started during capture. pub struct ActiveStreamingTranscription { controller: Option, startup_outcome: Option, } impl ActiveStreamingTranscription { + /// Inert handle when streaming mode is off or no route is configured. #[must_use] pub const fn disabled() -> Self { Self { @@ -283,10 +320,12 @@ impl ActiveStreamingTranscription { } } + /// Start streaming with the default built-in provider registry. pub async fn start(resolved: &ResolvedUtteranceConfig) -> Self { Self::start_with_providers(resolved, default_provider_entries()).await } + /// Start streaming using a custom provider registry (primarily for tests). pub async fn start_with_providers( resolved: &ResolvedUtteranceConfig, providers: impl IntoIterator, @@ -320,6 +359,7 @@ impl ActiveStreamingTranscription { } } + /// Frame sender for the capture path. `None` when startup failed or streaming is disabled. #[must_use] pub fn sink(&self) -> Option> { self.controller @@ -327,6 +367,8 @@ impl ActiveStreamingTranscription { .map(StreamingTranscriptionController::sink) } + /// Drain the session and return an outcome. Honors `streaming.finish_timeout_ms` and + /// `streaming.fallback_to_recorded_on_error` from config. pub async fn finish(self) -> Option { if let Some(controller) = self.controller { return controller.finish().await; @@ -334,6 +376,7 @@ impl ActiveStreamingTranscription { self.startup_outcome } + /// Abort the worker without requiring a finish outcome. pub async fn cancel(self) { if let Some(controller) = self.controller { controller.cancel().await; diff --git a/src/streaming_transcription/deepgram.rs b/src/streaming_transcription/deepgram.rs index a9e62ec..8d90503 100644 --- a/src/streaming_transcription/deepgram.rs +++ b/src/streaming_transcription/deepgram.rs @@ -1,3 +1,9 @@ +//! Deepgram live transcription over the streaming WebSocket `/v1/listen` API. +//! +//! Sends linear16 PCM in binary frames, accumulates `is_final` `Results` messages, +//! and closes with a `CloseStream` control message. Implements +//! [`StreamingTranscriptionProvider`] for [`TranscriptionProvider::Deepgram`]. + use async_trait::async_trait; use futures_util::{SinkExt, StreamExt}; use reqwest::Url; @@ -20,9 +26,11 @@ use crate::{ }; const PROVIDER: TranscriptionProvider = TranscriptionProvider::Deepgram; +const STREAM_CLOSED_WITH_ERROR_CODE: &str = "deepgram_closed_with_error"; const MISSING_API_KEY_CODE: &str = "missing_deepgram_api_key"; const CLOSE_STREAM_CONTROL: &str = r#"{"type":"CloseStream"}"#; +/// Deepgram [`StreamingTranscriptionProvider`] using `tokio-tungstenite`. #[derive(Debug, Clone, Copy, Default)] pub struct DeepgramStreamingTranscriptionProvider; @@ -221,6 +229,7 @@ where .send(Message::Text(CLOSE_STREAM_CONTROL.to_string())) .await?; + let mut error_close: Option = None; while let Some(message) = self.socket.next().await? { match message { Message::Text(text) => { @@ -230,11 +239,30 @@ where Message::Ping(payload) => { self.socket.send(Message::Pong(payload)).await?; } - Message::Close(_) => break, + Message::Close(frame) => { + if let Some(frame) = frame { + let code = u16::from(frame.code); + if !matches!(code, 1000 | 1001) { + error_close = Some(StreamingTranscriptionError::failed( + PROVIDER, + STREAM_CLOSED_WITH_ERROR_CODE, + format!( + "Deepgram closed the stream with error code {code}: {}", + frame.reason + ), + )); + } + } + break; + } } } - Ok(self.transcript.into_outcome()) + let outcome = self.transcript.into_outcome(); + match error_close { + Some(error) if !outcome.has_final_text() => Err(error), + _ => Ok(outcome), + } } async fn cancel(mut self: Box) { @@ -703,6 +731,55 @@ api_key = "secret" assert_eq!(state.text_payloads, vec![CLOSE_STREAM_CONTROL]); } + #[tokio::test(flavor = "current_thread")] + async fn finish_reports_error_close_frame_as_failure() { + use tokio_tungstenite::tungstenite::protocol::{frame::coding::CloseCode, CloseFrame}; + + let state = Arc::new(Mutex::new(FakeWebSocketState::default())); + let socket = FakeDeepgramWebSocket::new( + Arc::clone(&state), + [Message::Close(Some(CloseFrame { + code: CloseCode::Library(4001), + reason: "insufficient_credits".into(), + }))], + ); + + let error = Box::new(DeepgramStreamingSession::new(socket, 16_000, 1)) + .finish() + .await + .expect_err("an error close with no transcript must surface as a failure"); + + assert_eq!(error.to_attempt().code, "deepgram_closed_with_error"); + assert!(error.to_attempt().detail.contains("insufficient_credits")); + } + + #[tokio::test(flavor = "current_thread")] + async fn finish_keeps_transcript_when_error_close_follows_final_text() { + use tokio_tungstenite::tungstenite::protocol::{frame::coding::CloseCode, CloseFrame}; + + let state = Arc::new(Mutex::new(FakeWebSocketState::default())); + let socket = FakeDeepgramWebSocket::new( + Arc::clone(&state), + [ + Message::Text( + r#"{"type":"Results","is_final":true,"speech_final":true,"channel":{"alternatives":[{"transcript":"streamed text"}]}}"# + .to_string(), + ), + Message::Close(Some(CloseFrame { + code: CloseCode::Library(4001), + reason: "insufficient_credits".into(), + })), + ], + ); + + let outcome = Box::new(DeepgramStreamingSession::new(socket, 16_000, 1)) + .finish() + .await + .expect("a produced transcript should win over a trailing error close"); + + assert_eq!(outcome.raw_text.as_deref(), Some("streamed text")); + } + #[tokio::test(flavor = "current_thread")] async fn session_replies_to_ping_while_finishing() { let state = Arc::new(Mutex::new(FakeWebSocketState::default())); diff --git a/src/streaming_transcription/google.rs b/src/streaming_transcription/google.rs index e50a91e..884d1c3 100644 --- a/src/streaming_transcription/google.rs +++ b/src/streaming_transcription/google.rs @@ -1,3 +1,11 @@ +//! Google Cloud Speech v2 live transcription adapter. +//! +//! Builds `StreamingRecognize` requests with explicit linear16 decoding and +//! chunks audio at 15 KiB. The official `google-cloud-speech-v2` crate currently +//! lacks a callable streaming RPC, so connect reports +//! `google_official_client_streaming_rpc_unavailable` after credentials resolve. +//! Implements [`StreamingTranscriptionProvider`] for [`TranscriptionProvider::Google`]. + use std::marker::PhantomData; use async_trait::async_trait; @@ -22,6 +30,7 @@ const MISSING_PROJECT_ID_CODE: &str = "missing_google_streaming_project_id"; const OFFICIAL_STREAMING_RPC_UNAVAILABLE_CODE: &str = "google_official_client_streaming_rpc_unavailable"; +/// Google Speech v2 [`StreamingTranscriptionProvider`] (RPC stub until crate support lands). #[derive(Debug, Clone, Copy, Default)] pub struct GoogleStreamingTranscriptionProvider; diff --git a/src/streaming_transcription/openai.rs b/src/streaming_transcription/openai.rs index 7a2ebd7..6e7cfef 100644 --- a/src/streaming_transcription/openai.rs +++ b/src/streaming_transcription/openai.rs @@ -1,8 +1,16 @@ +//! OpenAI Realtime transcription over the streaming WebSocket API. +//! +//! Requires 24 kHz mono recording, sends base64 PCM via `input_audio_buffer.append`, +//! and waits for `conversation.item.input_audio_transcription.completed` events +//! after `input_audio_buffer.commit`. Implements [`StreamingTranscriptionProvider`] +//! for [`TranscriptionProvider::OpenAi`]. + use async_trait::async_trait; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use futures_util::{SinkExt, StreamExt}; use reqwest::Url; use serde_json::{json, Value}; +use std::collections::BTreeSet; use tokio::net::TcpStream; use tokio_tungstenite::{ connect_async, @@ -25,7 +33,9 @@ const MISSING_API_KEY_CODE: &str = "missing_openai_api_key"; const RECORDING_CONFIG_UNSUPPORTED_CODE: &str = "openai_realtime_recording_config_unsupported"; const REQUIRED_SAMPLE_RATE_HZ: u32 = 24_000; const REQUIRED_CHANNELS: u16 = 1; +const STREAM_CLOSED_WITH_ERROR_CODE: &str = "openai_realtime_closed_with_error"; +/// OpenAI Realtime [`StreamingTranscriptionProvider`] using `tokio-tungstenite`. #[derive(Debug, Clone, Copy, Default)] pub struct OpenAiStreamingTranscriptionProvider; @@ -275,12 +285,16 @@ where )) .await?; - while let Some(message) = self.socket.next().await? { + let mut error_close: Option = None; + loop { + let Some(message) = self.socket.next().await? else { + break; + }; + match message { Message::Text(text) => { - let completed_before = self.transcript.completed_count(); self.transcript.handle_text_message(&text)?; - if self.transcript.completed_count() > completed_before { + if self.transcript.committed_item_transcription_finished() { break; } } @@ -288,12 +302,31 @@ where Message::Ping(payload) => { self.socket.send(Message::Pong(payload)).await?; } - Message::Close(_) => break, + Message::Close(frame) => { + if let Some(frame) = frame { + let code = u16::from(frame.code); + if !matches!(code, 1000 | 1001) { + error_close = Some(StreamingTranscriptionError::failed( + PROVIDER, + STREAM_CLOSED_WITH_ERROR_CODE, + format!( + "OpenAI Realtime closed the stream with error code {code}: {}", + frame.reason + ), + )); + } + } + break; + } } } let _ = self.socket.close().await; - Ok(self.transcript.into_outcome()) + let outcome = self.transcript.into_outcome(); + match error_close { + Some(error) if !outcome.has_final_text() => Err(error), + _ => Ok(outcome), + } } async fn cancel(mut self: Box) { @@ -374,6 +407,8 @@ fn map_websocket_read_error( #[derive(Debug, Default)] struct OpenAiTranscriptAccumulator { partial_text: String, + committed_item_id: Option, + committed_audio_content_indexes: Option>, completed: Vec, } @@ -399,6 +434,8 @@ impl OpenAiTranscriptAccumulator { Some("conversation.item.input_audio_transcription.completed") => { self.handle_completed(&value) } + Some("input_audio_buffer.committed") => self.handle_buffer_committed(&value), + Some("conversation.item.done") => self.handle_item_done(&value), Some("error") => Err(openai_provider_error(&value)), Some(_) => Ok(()), None => Err(StreamingTranscriptionError::failed( @@ -463,13 +500,85 @@ impl OpenAiTranscriptAccumulator { Ok(()) } - fn completed_count(&self) -> usize { - self.completed.len() + fn handle_buffer_committed( + &mut self, + value: &Value, + ) -> Result<(), StreamingTranscriptionError> { + let Some(item_id) = value.get("item_id").and_then(Value::as_str) else { + return Err(StreamingTranscriptionError::failed( + PROVIDER, + "invalid_openai_realtime_committed", + format!( + "OpenAI Realtime committed event did not include a string item_id field: {value}" + ), + )); + }; + self.committed_item_id = Some(item_id.to_string()); + Ok(()) + } + + fn handle_item_done(&mut self, value: &Value) -> Result<(), StreamingTranscriptionError> { + let Some(item) = value.get("item").and_then(Value::as_object) else { + return Err(StreamingTranscriptionError::failed( + PROVIDER, + "invalid_openai_realtime_item_done", + format!("OpenAI Realtime item.done event did not include an item object: {value}"), + )); + }; + let Some(item_id) = item.get("id").and_then(Value::as_str) else { + return Err(StreamingTranscriptionError::failed( + PROVIDER, + "invalid_openai_realtime_item_done", + format!("OpenAI Realtime item.done event did not include a string item.id field: {value}"), + )); + }; + if self.committed_item_id.as_deref() != Some(item_id) { + return Ok(()); + } + let Some(content) = item.get("content").and_then(Value::as_array) else { + return Err(StreamingTranscriptionError::failed( + PROVIDER, + "invalid_openai_realtime_item_done", + format!("OpenAI Realtime item.done event did not include an item.content array: {value}"), + )); + }; + + let mut indexes = content + .iter() + .enumerate() + .filter_map(|(index, part)| is_input_audio_content_part(part).then_some(index as u64)) + .collect::>(); + + if indexes.is_empty() { + indexes.insert(0); + } + self.committed_audio_content_indexes = Some(indexes); + Ok(()) + } + + fn committed_item_transcription_finished(&self) -> bool { + let Some(committed_item_id) = self.committed_item_id.as_deref() else { + return false; + }; + let Some(audio_content_indexes) = self.committed_audio_content_indexes.as_ref() else { + return false; + }; + + audio_content_indexes.iter().all(|content_index| { + self.completed.iter().any(|entry| { + entry.item_id == committed_item_id && entry.content_index == *content_index + }) + }) } fn into_outcome(self) -> StreamingTranscriptOutcome { - let raw_text = self - .completed + let mut completed = self.completed; + if let Some(committed_item_id) = self.committed_item_id.as_deref() { + completed.retain(|entry| entry.item_id == committed_item_id); + completed.sort_by_key(|entry| entry.content_index); + } + + let raw_text = completed .into_iter() .filter_map(|entry| { let transcript = entry.transcript.trim(); @@ -489,6 +598,13 @@ impl OpenAiTranscriptAccumulator { } } +fn is_input_audio_content_part(part: &Value) -> bool { + matches!( + part.get("type").and_then(Value::as_str), + Some("input_audio" | "audio") + ) || part.get("transcript").is_some() +} + fn openai_provider_error(value: &Value) -> StreamingTranscriptionError { let error = value.get("error").unwrap_or(value); let code = error @@ -852,6 +968,197 @@ api_key = "config-key" assert_eq!(state.close_calls, 1); } + #[tokio::test(flavor = "current_thread")] + async fn finish_accumulates_multiple_completed_segments() { + let state = Arc::new(Mutex::new(FakeWebSocketState::default())); + let socket = FakeOpenAiWebSocket::new( + Arc::clone(&state), + [ + Message::Text( + r#"{"type":"input_audio_buffer.committed","item_id":"item_002"}"#.to_string(), + ), + Message::Text( + r#"{"type":"conversation.item.done","item":{"id":"item_002","content":[{"type":"input_audio"},{"type":"input_audio"}]}}"# + .to_string(), + ), + Message::Text( + r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"item_001","content_index":0,"transcript":"previous segment"}"# + .to_string(), + ), + Message::Text( + r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"item_002","content_index":0,"transcript":"first segment"}"# + .to_string(), + ), + Message::Text( + r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"item_002","content_index":1,"transcript":"second segment"}"# + .to_string(), + ), + ], + ); + + let outcome = Box::new(OpenAiStreamingSession::new(socket, request())) + .finish() + .await + .expect("session should finalize"); + + assert_eq!( + outcome.raw_text.as_deref(), + Some("first segment second segment") + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn finish_waits_for_delayed_completion_for_committed_item() { + let state = Arc::new(Mutex::new(FakeWebSocketState::default())); + let socket = FakeOpenAiWebSocket::new_delayed( + Arc::clone(&state), + [ + FakeIncoming::message(Message::Text( + r#"{"type":"input_audio_buffer.committed","item_id":"item_001"}"#.to_string(), + )), + FakeIncoming::message(Message::Text( + r#"{"type":"conversation.item.done","item":{"id":"item_001","content":[{"type":"input_audio"}]}}"# + .to_string(), + )), + FakeIncoming::delayed( + std::time::Duration::from_millis(350), + Message::Text( + r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"item_001","content_index":0,"transcript":"delayed final text"}"# + .to_string(), + ), + ), + ], + ); + + let outcome = Box::new(OpenAiStreamingSession::new(socket, request())) + .finish() + .await + .expect("session should wait for the committed item's delayed completion"); + + assert_eq!(outcome.raw_text.as_deref(), Some("delayed final text")); + } + + #[tokio::test(flavor = "current_thread")] + async fn finish_waits_for_all_finalized_audio_content_indexes() { + let state = Arc::new(Mutex::new(FakeWebSocketState::default())); + let socket = FakeOpenAiWebSocket::new_delayed( + Arc::clone(&state), + [ + FakeIncoming::message(Message::Text( + r#"{"type":"input_audio_buffer.committed","item_id":"item_001"}"#.to_string(), + )), + FakeIncoming::message(Message::Text( + r#"{"type":"conversation.item.done","item":{"id":"item_001","content":[{"type":"input_audio"},{"type":"input_audio"}]}}"# + .to_string(), + )), + FakeIncoming::message(Message::Text( + r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"item_001","content_index":0,"transcript":"first part"}"# + .to_string(), + )), + FakeIncoming::delayed( + std::time::Duration::from_millis(350), + Message::Text( + r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"item_001","content_index":1,"transcript":"second part"}"# + .to_string(), + ), + ), + ], + ); + + let outcome = Box::new(OpenAiStreamingSession::new(socket, request())) + .finish() + .await + .expect("session should wait for all finalized audio content indexes"); + + assert_eq!(outcome.raw_text.as_deref(), Some("first part second part")); + } + + #[tokio::test(flavor = "current_thread")] + async fn finish_orders_committed_audio_parts_by_content_index() { + let state = Arc::new(Mutex::new(FakeWebSocketState::default())); + let socket = FakeOpenAiWebSocket::new( + Arc::clone(&state), + [ + Message::Text( + r#"{"type":"input_audio_buffer.committed","item_id":"item_001"}"#.to_string(), + ), + Message::Text( + r#"{"type":"conversation.item.done","item":{"id":"item_001","content":[{"type":"input_audio"},{"type":"input_audio"}]}}"# + .to_string(), + ), + Message::Text( + r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"item_001","content_index":1,"transcript":"second part"}"# + .to_string(), + ), + Message::Text( + r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"item_001","content_index":0,"transcript":"first part"}"# + .to_string(), + ), + ], + ); + + let outcome = Box::new(OpenAiStreamingSession::new(socket, request())) + .finish() + .await + .expect("session should order finalized audio content indexes"); + + assert_eq!(outcome.raw_text.as_deref(), Some("first part second part")); + } + + #[tokio::test(flavor = "current_thread")] + async fn finish_skips_empty_first_completion_for_later_text() { + let state = Arc::new(Mutex::new(FakeWebSocketState::default())); + let socket = FakeOpenAiWebSocket::new( + Arc::clone(&state), + [ + Message::Text( + r#"{"type":"input_audio_buffer.committed","item_id":"item_002"}"#.to_string(), + ), + Message::Text( + r#"{"type":"conversation.item.done","item":{"id":"item_002","content":[{"type":"input_audio"}]}}"# + .to_string(), + ), + Message::Text( + r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"item_001","content_index":0,"transcript":" "}"# + .to_string(), + ), + Message::Text( + r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"item_002","content_index":0,"transcript":"real text"}"# + .to_string(), + ), + ], + ); + + let outcome = Box::new(OpenAiStreamingSession::new(socket, request())) + .finish() + .await + .expect("session should finalize"); + + assert_eq!(outcome.raw_text.as_deref(), Some("real text")); + } + + #[tokio::test(flavor = "current_thread")] + async fn finish_reports_error_close_frame_as_failure() { + use tokio_tungstenite::tungstenite::protocol::{frame::coding::CloseCode, CloseFrame}; + + let state = Arc::new(Mutex::new(FakeWebSocketState::default())); + let socket = FakeOpenAiWebSocket::new( + Arc::clone(&state), + [Message::Close(Some(CloseFrame { + code: CloseCode::Policy, + reason: "insufficient_quota".into(), + }))], + ); + + let error = Box::new(OpenAiStreamingSession::new(socket, request())) + .finish() + .await + .expect_err("an error close with no transcript must surface as a failure"); + + assert_eq!(error.to_attempt().code, "openai_realtime_closed_with_error"); + assert!(error.to_attempt().detail.contains("insufficient_quota")); + } + #[tokio::test(flavor = "current_thread")] async fn session_replies_to_ping_while_finishing() { let state = Arc::new(Mutex::new(FakeWebSocketState::default())); @@ -949,13 +1256,31 @@ api_key = "config-key" struct FakeOpenAiWebSocket { state: Arc>, - incoming: VecDeque, + incoming: VecDeque, + } + + struct FakeIncoming { + delay: Option, + message: Message, } impl FakeOpenAiWebSocket { fn new( state: Arc>, incoming: impl IntoIterator, + ) -> Self { + Self::new_delayed( + state, + incoming + .into_iter() + .map(FakeIncoming::message) + .collect::>(), + ) + } + + fn new_delayed( + state: Arc>, + incoming: impl IntoIterator, ) -> Self { Self { state, @@ -964,6 +1289,22 @@ api_key = "config-key" } } + impl FakeIncoming { + fn message(message: Message) -> Self { + Self { + delay: None, + message, + } + } + + fn delayed(delay: std::time::Duration, message: Message) -> Self { + Self { + delay: Some(delay), + message, + } + } + } + #[async_trait] impl OpenAiWebSocket for FakeOpenAiWebSocket { async fn send(&mut self, message: Message) -> Result<(), StreamingTranscriptionError> { @@ -982,7 +1323,13 @@ api_key = "config-key" } async fn next(&mut self) -> Result, StreamingTranscriptionError> { - Ok(self.incoming.pop_front()) + let Some(incoming) = self.incoming.pop_front() else { + return Ok(None); + }; + if let Some(delay) = incoming.delay { + tokio::time::sleep(delay).await; + } + Ok(Some(incoming.message)) } } diff --git a/src/stt_apple_speech_tool.rs b/src/stt_apple_speech_tool.rs index c6d8a5f..3d8b80f 100644 --- a/src/stt_apple_speech_tool.rs +++ b/src/stt_apple_speech_tool.rs @@ -1,9 +1,15 @@ +//! Builtin Apple Speech STT pipeline step. +//! +//! Materializes an embedded macOS helper binary, transcribes `audio.wav_path` +//! via on-device Speech APIs, and writes `transcript.raw_text`. Runnable as a +//! subprocess internal tool or in-process via [`process_input_in_process`]. + use std::fs; use std::io::ErrorKind; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode, Output, Stdio}; -use std::sync::{Mutex, OnceLock}; +use std::sync::Mutex; use muninn::MuninnEnvelopeV1; use muninn::ResolvedBuiltinStepConfig; @@ -23,6 +29,7 @@ const PROVIDER_ID: &str = "apple_speech"; const EMBEDDED_HELPER_BYTES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/apple_speech_transcriber")); +/// Apple Speech step failure surfaced to the pipeline runner or internal-tool stderr. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CliError { code: &'static str, @@ -37,6 +44,7 @@ impl CliError { } } + /// Serialize the error as JSON for subprocess stderr consumers. pub(crate) fn to_stderr_json(&self) -> String { json!({ "error": { @@ -47,6 +55,7 @@ impl CliError { .to_string() } + /// Borrow the human-readable error message. pub(crate) fn message(&self) -> &str { &self.message } @@ -122,6 +131,10 @@ struct AppleSpeechHelperResponse { asset_status: Option, } +/// Entry point for `muninn __internal_step stt_apple_speech` subprocess invocation. +/// +/// Reads a [`MuninnEnvelopeV1`] from stdin and writes the updated envelope to +/// stdout; failures log and emit JSON on stderr before returning failure. pub fn run_as_internal_tool() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, @@ -207,6 +220,7 @@ where } } +/// Run Apple Speech STT inside the pipeline process using resolved builtin configuration. pub(crate) async fn process_input_in_process( input: &MuninnEnvelopeV1, config: &ResolvedBuiltinStepConfig, @@ -613,13 +627,8 @@ fn resolved_config_from_builtin_steps( } fn materialize_helper_binary() -> Result { - static HELPER_PATH: OnceLock = OnceLock::new(); static HELPER_INIT: Mutex<()> = Mutex::new(()); - if let Some(path) = HELPER_PATH.get() { - return Ok(path.clone()); - } - let _guard = HELPER_INIT.lock().map_err(|_| { CliError::new( "apple_speech_helper_lock_failed", @@ -627,10 +636,6 @@ fn materialize_helper_binary() -> Result { ) })?; - if let Some(path) = HELPER_PATH.get() { - return Ok(path.clone()); - } - let path = helper_output_path(); if helper_needs_refresh(&path)? { if let Some(parent) = path.parent() { @@ -649,7 +654,6 @@ fn materialize_helper_binary() -> Result { } ensure_helper_permissions(&path)?; - let _ = HELPER_PATH.set(path.clone()); Ok(path) } diff --git a/src/stt_deepgram_tool.rs b/src/stt_deepgram_tool.rs index 3313638..c4d3ea4 100644 --- a/src/stt_deepgram_tool.rs +++ b/src/stt_deepgram_tool.rs @@ -1,3 +1,9 @@ +//! Builtin Deepgram STT pipeline step. +//! +//! Uploads `audio.wav_path` to the Deepgram listen REST API and writes +//! `transcript.raw_text`. Runnable as a subprocess internal tool or in-process +//! via [`process_input_in_process`]. + use muninn::resolve_secret; use muninn::MuninnEnvelopeV1; use muninn::ResolvedBuiltinStepConfig; @@ -20,6 +26,7 @@ const DEFAULT_ENDPOINT: &str = "https://api.deepgram.com/v1/listen"; const DEFAULT_MODEL: &str = "nova-3"; const DEFAULT_LANGUAGE: &str = "en"; +/// Deepgram step failure surfaced to the pipeline runner or internal-tool stderr. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CliError { code: &'static str, @@ -34,6 +41,7 @@ impl CliError { } } + /// Serialize the error as JSON for subprocess stderr consumers. pub(crate) fn to_stderr_json(&self) -> String { json!({ "error": { @@ -44,6 +52,7 @@ impl CliError { .to_string() } + /// Borrow the human-readable error message. pub(crate) fn message(&self) -> &str { &self.message } @@ -103,6 +112,10 @@ enum PreparedEnvelope { NeedsTranscription(PreparedTranscriptionRequest), } +/// Entry point for `muninn __internal_step stt_deepgram` subprocess invocation. +/// +/// Reads a [`MuninnEnvelopeV1`] from stdin and writes the updated envelope to +/// stdout; failures log and emit JSON on stderr before returning failure. pub fn run_as_internal_tool() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, @@ -180,6 +193,7 @@ where } } +/// Run Deepgram STT inside the pipeline process using resolved builtin configuration. pub(crate) async fn process_input_in_process( input: &MuninnEnvelopeV1, config: &ResolvedBuiltinStepConfig, diff --git a/src/stt_google_tool.rs b/src/stt_google_tool.rs index 15b9db0..0f12c2f 100644 --- a/src/stt_google_tool.rs +++ b/src/stt_google_tool.rs @@ -1,3 +1,9 @@ +//! Builtin Google Cloud Speech STT pipeline step. +//! +//! Base64-encodes `audio.wav_path` into a `speech:recognize` REST request and +//! writes `transcript.raw_text`. Runnable as a subprocess internal tool or +//! in-process via [`process_input_in_process`]. + use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use muninn::resolve_secret; use muninn::MuninnEnvelopeV1; @@ -21,6 +27,7 @@ use tracing::{error, info, warn}; const DEFAULT_LANGUAGE_CODE: &str = "en-US"; const PROVIDER_LOG_TARGET: &str = muninn::TARGET_PROVIDER; +/// Google STT step failure surfaced to the pipeline runner or internal-tool stderr. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CliError { code: &'static str, @@ -35,6 +42,7 @@ impl CliError { } } + /// Serialize the error as JSON for subprocess stderr consumers. pub(crate) fn to_stderr_json(&self) -> String { json!({ "error": { @@ -45,6 +53,7 @@ impl CliError { .to_string() } + /// Borrow the human-readable error message. pub(crate) fn message(&self) -> &str { &self.message } @@ -132,6 +141,10 @@ struct GoogleRecognitionConfig<'a> { model: Option<&'a str>, } +/// Entry point for `muninn __internal_step stt_google` subprocess invocation. +/// +/// Reads a [`MuninnEnvelopeV1`] from stdin and writes the updated envelope to +/// stdout; failures log and emit JSON on stderr before returning failure. pub fn run_as_internal_tool() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, @@ -203,6 +216,7 @@ where } } +/// Run Google STT inside the pipeline process using resolved builtin configuration. pub(crate) async fn process_input_in_process( input: &MuninnEnvelopeV1, config: &ResolvedBuiltinStepConfig, @@ -597,13 +611,19 @@ fn extract_google_transcript_text(body: &[u8]) -> Result { .and_then(Value::as_array) .into_iter() .flatten() - .filter_map(|result| result.get("alternatives").and_then(Value::as_array)) - .flatten() - .filter_map(|alternative| alternative.get("transcript").and_then(Value::as_str)) - .next() + .filter_map(|result| { + result + .get("alternatives") + .and_then(Value::as_array) + .and_then(|alternatives| alternatives.first()) + .and_then(|alternative| alternative.get("transcript")) + .and_then(Value::as_str) + }) .map(str::trim) - .unwrap_or_default() - .to_string(); + .filter(|segment| !segment.is_empty()) + .collect::>() + .join(" "); + let transcript = transcript.trim().to_string(); if !transcript.is_empty() || value.get("results").is_some() { return Ok(transcript); @@ -959,6 +979,24 @@ mod tests { assert_eq!(transcript, "hello from google"); } + #[test] + fn response_json_joins_multiple_result_segments() { + let transcript = extract_google_transcript_text( + br#"{"results":[{"alternatives":[{"transcript":"hello world"}]},{"alternatives":[{"transcript":"goodbye now"}]}]}"#, + ) + .expect("extract text from multi-segment json"); + assert_eq!(transcript, "hello world goodbye now"); + } + + #[test] + fn response_json_uses_first_alternative_per_result() { + let transcript = extract_google_transcript_text( + br#"{"results":[{"alternatives":[{"transcript":"primary","confidence":0.9},{"transcript":"secondary","confidence":0.4}]}]}"#, + ) + .expect("extract text using first alternative"); + assert_eq!(transcript, "primary"); + } + #[test] fn response_json_with_empty_results_returns_empty_string() { let transcript = extract_google_transcript_text(br#"{"results":[]}"#) diff --git a/src/stt_openai_tool.rs b/src/stt_openai_tool.rs index b06631f..391e601 100644 --- a/src/stt_openai_tool.rs +++ b/src/stt_openai_tool.rs @@ -1,3 +1,9 @@ +//! Builtin OpenAI STT pipeline step. +//! +//! Uploads `audio.wav_path` to the OpenAI audio transcriptions REST API and +//! writes `transcript.raw_text`. Runnable as a subprocess internal tool or +//! in-process via [`process_input_in_process`]. + use muninn::resolve_secret; use muninn::MuninnEnvelopeV1; use muninn::ResolvedBuiltinStepConfig; @@ -16,6 +22,7 @@ use tracing::{error, info, warn}; const OPENAI_AUDIO_UPLOAD_MAX_BYTES: u64 = 25_000_000; const MIN_AUDIO_UPLOAD_BYTES: u64 = 45; +/// OpenAI STT step failure surfaced to the pipeline runner or internal-tool stderr. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CliError { code: &'static str, @@ -30,6 +37,7 @@ impl CliError { } } + /// Serialize the error as JSON for subprocess stderr consumers. pub(crate) fn to_stderr_json(&self) -> String { json!({ "error": { @@ -40,6 +48,7 @@ impl CliError { .to_string() } + /// Borrow the human-readable error message. pub(crate) fn message(&self) -> &str { &self.message } @@ -97,6 +106,10 @@ enum PreparedEnvelope { NeedsTranscription(PreparedTranscriptionRequest), } +/// Entry point for `muninn __internal_step stt_openai` subprocess invocation. +/// +/// Reads a [`MuninnEnvelopeV1`] from stdin and writes the updated envelope to +/// stdout; failures log and emit JSON on stderr before returning failure. pub fn run_as_internal_tool() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, @@ -168,6 +181,7 @@ where } } +/// Run OpenAI STT inside the pipeline process using resolved builtin configuration. pub(crate) async fn process_input_in_process( input: &MuninnEnvelopeV1, config: &ResolvedBuiltinStepConfig, diff --git a/src/stt_whisper_cpp_tool.rs b/src/stt_whisper_cpp_tool.rs index 6bee1a1..c303f54 100644 --- a/src/stt_whisper_cpp_tool.rs +++ b/src/stt_whisper_cpp_tool.rs @@ -1,3 +1,10 @@ +//! Builtin whisper.cpp STT pipeline step. +//! +//! Loads a local GGML model (auto-downloading defaults when configured), +//! transcribes `audio.wav_path` on a blocking worker thread, and writes +//! `transcript.raw_text`. Runnable as a subprocess internal tool or in-process +//! via [`process_input_in_process`]. + use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; @@ -24,6 +31,7 @@ const TARGET_SAMPLE_RATE_HZ: u32 = 16_000; const MODEL_DOWNLOAD_WAIT_TIMEOUT: Duration = Duration::from_secs(300); const MODEL_DOWNLOAD_WAIT_INTERVAL: Duration = Duration::from_millis(250); +/// Whisper.cpp step failure surfaced to the pipeline runner or internal-tool stderr. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CliError { code: &'static str, @@ -38,6 +46,7 @@ impl CliError { } } + /// Serialize the error as JSON for subprocess stderr consumers. pub(crate) fn to_stderr_json(&self) -> String { json!({ "error": { @@ -48,6 +57,7 @@ impl CliError { .to_string() } + /// Borrow the human-readable error message. pub(crate) fn message(&self) -> &str { &self.message } @@ -155,6 +165,10 @@ impl Drop for ModelDownloadLock { } } +/// Entry point for `muninn __internal_step stt_whisper_cpp` subprocess invocation. +/// +/// Reads a [`MuninnEnvelopeV1`] from stdin and writes the updated envelope to +/// stdout; failures log and emit JSON on stderr before returning failure. pub fn run_as_internal_tool() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, @@ -264,6 +278,7 @@ async fn run_transcription_request( } } +/// Run whisper.cpp STT inside the pipeline process using resolved builtin configuration. pub(crate) async fn process_input_in_process( input: &MuninnEnvelopeV1, config: &ResolvedBuiltinStepConfig, diff --git a/src/target_context.rs b/src/target_context.rs index 3a1806b..0e53437 100644 --- a/src/target_context.rs +++ b/src/target_context.rs @@ -1,6 +1,16 @@ +//! Frontmost-application snapshot for profile matching and replay metadata. +//! +//! On macOS, reads the active app via `NSWorkspace` and best-effort window +//! title via `CGWindowListCopyWindowInfo`. On other targets, returns an empty +//! snapshot with a timestamp only. + use chrono::Utc; use serde::{Deserialize, Serialize}; +/// Point-in-time description of the focused application and window. +/// +/// Serialized into utterance metadata and used as a profile-matching key. +/// Missing fields mean the value could not be read or was blank after trimming. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct TargetContextSnapshot { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -13,6 +23,7 @@ pub struct TargetContextSnapshot { } impl TargetContextSnapshot { + /// Empty context fields with `captured_at` set to the current UTC timestamp. #[must_use] pub fn empty_now() -> Self { Self { @@ -24,6 +35,12 @@ impl TargetContextSnapshot { } } +/// Capture the frontmost application context for the current platform. +/// +/// On macOS, queries `NSWorkspace` for bundle ID and localized name, then +/// scans on-screen windows for a title owned by the frontmost PID. Prefers a +/// layer-0 window title when multiple windows match. On non-macOS targets, +/// returns [`TargetContextSnapshot::empty_now`] without reading the desktop. #[must_use] pub fn capture_frontmost_target_context() -> TargetContextSnapshot { #[cfg(target_os = "macos")] diff --git a/src/transcription.rs b/src/transcription.rs index c95f78b..97f3014 100644 --- a/src/transcription.rs +++ b/src/transcription.rs @@ -1,3 +1,9 @@ +//! Transcription provider registry and envelope metadata for STT routing. +//! +//! Defines the canonical provider vocabulary, default fallback order, and +//! helpers that attach resolved routes and per-provider attempt records into +//! `envelope.extra["transcription"]` for pipeline diagnostics. + use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -24,28 +30,37 @@ const DEFAULT_PROVIDER_ROUTE: [TranscriptionProvider; 5] = [ TranscriptionProvider::Google, ]; +/// Supported speech-to-text backends and their config/step identifiers. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] pub enum TranscriptionProvider { + /// On-device Apple Speech framework. AppleSpeech, + /// Local `whisper.cpp` inference. WhisperCpp, + /// Deepgram cloud API. Deepgram, + /// OpenAI transcription API. #[serde(rename = "openai")] OpenAi, + /// Google speech-to-text API. Google, } impl TranscriptionProvider { + /// All known providers in declaration order. #[must_use] pub const fn all() -> &'static [Self] { &ALL_PROVIDERS } + /// Default provider attempt order when configuration does not override it. #[must_use] pub const fn default_ordered_route() -> &'static [Self] { &DEFAULT_PROVIDER_ROUTE } + /// Snake-case id used under `providers.*` in configuration. #[must_use] pub const fn config_id(self) -> &'static str { match self { @@ -57,6 +72,7 @@ impl TranscriptionProvider { } } + /// Builtin pipeline step command name for this provider. #[must_use] pub const fn canonical_step_name(self) -> &'static str { match self { @@ -68,6 +84,7 @@ impl TranscriptionProvider { } } + /// Suggested per-step timeout when configuration leaves `timeout_ms` unset. #[must_use] pub const fn default_timeout_ms(self) -> u64 { match self { @@ -77,11 +94,13 @@ impl TranscriptionProvider { } } + /// True for on-device providers that do not require cloud credentials. #[must_use] pub const fn is_local(self) -> bool { matches!(self, Self::AppleSpeech | Self::WhisperCpp) } + /// Parse a configuration provider id into a known variant. #[must_use] pub fn lookup_config_id(raw: &str) -> Option { match raw { @@ -94,6 +113,7 @@ impl TranscriptionProvider { } } + /// Parse a pipeline step command name into a known variant. #[must_use] pub fn lookup_step_name(raw: &str) -> Option { match raw { @@ -113,13 +133,17 @@ impl std::fmt::Display for TranscriptionProvider { } } +/// How the transcription provider route was chosen. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum TranscriptionRouteSource { + /// User configuration supplied an explicit provider list. ExplicitConfig, + /// Route inferred from pipeline builtin step ordering. PipelineInferred, } +/// Ordered provider list attached to an envelope before STT attempts run. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ResolvedTranscriptionRoute { pub providers: Vec, @@ -127,24 +151,34 @@ pub struct ResolvedTranscriptionRoute { } impl ResolvedTranscriptionRoute { + /// True when no providers remain to attempt. #[must_use] pub fn is_empty(&self) -> bool { self.providers.is_empty() } } +/// Result category for one provider attempt in the transcription chain. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum TranscriptionAttemptOutcome { + /// Provider returned non-empty `transcript.raw_text`. ProducedTranscript, + /// Provider succeeded but produced no usable transcript text. EmptyTranscript, + /// Provider is unsupported on the current platform. UnavailablePlatform, + /// Required API keys or secrets were missing. UnavailableCredentials, + /// Local models or assets required by the provider were missing. UnavailableAssets, + /// Runtime capability (microphone, speech framework, etc.) was unavailable. UnavailableRuntimeCapability, + /// Provider request failed after prerequisites were satisfied. RequestFailed, } +/// One STT provider attempt recorded on the envelope for diagnostics. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TranscriptionAttempt { pub provider: TranscriptionProvider, @@ -155,6 +189,7 @@ pub struct TranscriptionAttempt { } impl TranscriptionAttempt { + /// Build an attempt record with the provider's canonical step name. #[must_use] pub fn new( provider: TranscriptionProvider, @@ -172,6 +207,7 @@ impl TranscriptionAttempt { } } +/// Store `route` under `envelope.extra["transcription"]["route"]`. pub fn attach_transcription_route( envelope: &mut MuninnEnvelopeV1, route: &ResolvedTranscriptionRoute, @@ -182,6 +218,7 @@ pub fn attach_transcription_route( ); } +/// Append `attempt` to `envelope.extra["transcription"]["attempts"]`. pub fn append_transcription_attempt( envelope: &mut MuninnEnvelopeV1, attempt: TranscriptionAttempt, @@ -201,6 +238,7 @@ pub fn append_transcription_attempt( items.push(serde_json::to_value(attempt).expect("transcription attempt should serialize")); } +/// Read all recorded transcription attempts from the envelope extras. #[must_use] pub fn transcription_attempts(envelope: &MuninnEnvelopeV1) -> Vec { envelope @@ -218,6 +256,7 @@ pub fn transcription_attempts(envelope: &MuninnEnvelopeV1) -> Vec