From 7bc21d1f2969e86b30d271b26c29df1e3f89bab9 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Fri, 26 Jun 2026 19:31:32 +0100 Subject: [PATCH 01/16] fix(apple-speech): re-verify helper integrity on every spawn (P0 TOCTOU) The embedded Apple Speech helper path was cached in a OnceLock after first materialization, so later transcriptions returned the cached path without re-running helper_needs_refresh(). A same-user attacker could replace the on-disk helper at its predictable temp path and have Muninn execute the tampered binary on the next dictation. Remove the OnceLock cache and re-validate the on-disk bytes against the embedded helper on every call, rewriting atomically when they differ. The HELPER_INIT mutex still serializes concurrent refreshes. Ref: .devana/...-P0-apple-speech-helper-toctou.md --- src/stt_apple_speech_tool.rs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/stt_apple_speech_tool.rs b/src/stt_apple_speech_tool.rs index c6d8a5f..59e528e 100644 --- a/src/stt_apple_speech_tool.rs +++ b/src/stt_apple_speech_tool.rs @@ -3,7 +3,7 @@ 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; @@ -613,13 +613,13 @@ 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()); - } - + // Re-validate the on-disk helper against the embedded bytes on every call + // rather than caching the path. A cached path would let a same-user attacker + // replace the materialized binary after first use and have Muninn execute the + // tampered file on the next dictation (TOCTOU). The mutex serializes concurrent + // refreshes so two callers never stage/rename the helper at the same time. let _guard = HELPER_INIT.lock().map_err(|_| { CliError::new( "apple_speech_helper_lock_failed", @@ -627,10 +627,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 +645,6 @@ fn materialize_helper_binary() -> Result { } ensure_helper_permissions(&path)?; - let _ = HELPER_PATH.set(path.clone()); Ok(path) } From e9c1d8878d180ca855a6a8cefa4ffd511a883641 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Fri, 26 Jun 2026 19:36:57 +0100 Subject: [PATCH 02/16] fix(injection): proceed instead of aborting after a fresh Accessibility grant (P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit should_abort_injection aborted whenever Accessibility was requested during the interaction, even when the refreshed preflight already reported it granted. That returned Ok(()) without injecting, then process_and_inject deleted the temp WAV — discarding a fully transcribed utterance with no retry path, forcing re-dictation. Since the refreshed preflight confirms AXIsProcessTrusted is now true, injection will succeed, so proceed instead of aborting. Genuine denials still abort. Update the encoding test accordingly and add a still-denied abort case. Ref: .devana/...-P1-accessibility-grant-loses-transcript.md --- src/main.rs | 19 +++++++++++++++++-- src/runtime_permissions.rs | 20 +++++++++++++------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index fc06072..b1d5b2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -555,13 +555,28 @@ 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() { + // A fresh Accessibility grant that the refreshed preflight already reflects + // must not abort: the utterance is fully transcribed and aborting would + // delete the WAV with no retry path, losing the transcript. + 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/runtime_permissions.rs b/src/runtime_permissions.rs index b0e30c6..21b1627 100644 --- a/src/runtime_permissions.rs +++ b/src/runtime_permissions.rs @@ -200,14 +200,20 @@ pub(crate) fn should_abort_injection( 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 { + // Accessibility was requested this interaction and the refreshed + // preflight now reports it granted (AXIsProcessTrusted is true), so + // injection will succeed. Proceed instead of aborting: aborting here + // discards an already-transcribed utterance and deletes its WAV with + // no retry path, forcing the user to re-dictate. + 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 From 3a88e27827585961ceb3e7ee68161bb0eeceec45 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Fri, 26 Jun 2026 19:43:18 +0100 Subject: [PATCH 03/16] fix(replay): retain temp WAV until async persist completes (P1) Replay persistence is enqueued to a background worker via try_send, but the runtime deleted the temp WAV synchronously right after injection. When replay_retain_audio was enabled the worker frequently found the source file already gone and silently skipped audio retention, defeating full-debug replay. Transfer WAV ownership to the worker when the request is accepted: enqueue now returns whether the worker took ownership, the worker deletes the WAV only after persist (and thus after audio retention), and the runtime deletes it itself only when the request was not enqueued (replay disabled, queue full, or disconnected). The service's Drop already joins the worker, so shutdown still persists+cleans up. Ref: .devana/...-P1-replay-audio-deleted-before-persist.md --- src/replay_dispatch.rs | 142 +++++++++++++++++++++++----------------- src/runtime_pipeline.rs | 15 ++++- 2 files changed, 94 insertions(+), 63 deletions(-) diff --git a/src/replay_dispatch.rs b/src/replay_dispatch.rs index 518f2b3..a14862b 100644 --- a/src/replay_dispatch.rs +++ b/src/replay_dispatch.rs @@ -31,62 +31,12 @@ impl ReplayPersistenceService { 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" - ); - } - } + // The worker owns the temp WAV for this request. Capture its path so + // we can delete it after persistence (and thus after audio retention) + // regardless of which branch persist_request returns through. + let wav_path = request.recorded.wav_path.clone(); + persist_request(&mut stores, request); + crate::runtime_pipeline::cleanup_recording_file(&wav_path); } }); @@ -96,6 +46,14 @@ impl ReplayPersistenceService { } } + /// Hand a replay persistence request to the background worker. + /// + /// Returns `true` when the worker has accepted the request and thereby taken + /// ownership of the recorded temp WAV: the worker deletes it only after audio + /// retention has had a chance to copy it. Returns `false` when replay is + /// disabled or the request was dropped, in which case the caller remains + /// responsible for deleting the WAV. This ordering prevents a race where the + /// runtime deletes the temp WAV before the async worker can retain its audio. pub(crate) fn enqueue( &self, resolved: Option, @@ -103,16 +61,16 @@ impl ReplayPersistenceService { outcome: PipelineOutcome, route: InjectionRoute, recorded: RecordedAudio, - ) { + ) -> bool { let Some(resolved) = resolved else { - return; + 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,21 +80,85 @@ 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" + ); + } } } diff --git a/src/runtime_pipeline.rs b/src/runtime_pipeline.rs index b5754c8..9f9b4c1 100644 --- a/src/runtime_pipeline.rs +++ b/src/runtime_pipeline.rs @@ -45,6 +45,12 @@ where replay_persist, streaming_outcome, } = context; + // When the replay worker accepts the request it takes ownership of the temp + // WAV and deletes it only after audio retention completes. Otherwise this + // function must delete the WAV itself (see cleanup below). Default to false so + // that any early return — including an injection error after enqueue — still + // leaves cleanup with whoever actually owns the file. + 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 +79,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 +148,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() @@ -545,7 +554,7 @@ fn resolve_step_command(cmd: &str) -> Result { Ok(cmd.to_string()) } -fn cleanup_recording_file(path: &Path) { +pub(crate) fn cleanup_recording_file(path: &Path) { if let Err(error) = std::fs::remove_file(path) { warn!( target: logging::TARGET_RECORDING, From 7d19a84138e069df4799ca6bea5dd791ed7c9aac Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Fri, 26 Jun 2026 19:49:17 +0100 Subject: [PATCH 04/16] fix(replay): redact refine system prompt in full-debug record.json (P1) README documents that full-debug replay redacts prompt fields, and the config and envelope snapshots already strip system_prompt, but replay_refine_context wrote the verbatim transcript.system_prompt into record.json under refine_context.system_prompt. That leaked private vocabulary, names, and project hints into replay directories that may be shared or backed up. Store the [redacted] marker instead of the prompt text, keeping the field present so the artifact still records that a refine prompt was configured. Update the regression test to assert redaction. Ref: .devana/...-P1-replay-prompt-leak-refine-context.md --- src/replay.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/replay.rs b/src/replay.rs index 3c33450..0b78ab5 100644 --- a/src/replay.rs +++ b/src/replay.rs @@ -357,8 +357,13 @@ fn replay_refine_context(config: &AppConfig) -> Option { return None; } + // Redact the prompt text. The README promises full-debug replay redacts prompt + // fields, and config/envelope snapshots already strip system_prompt; persisting + // the verbatim refine prompt here would leak private vocabulary, names, and + // project hints into shared/backed-up replay directories. Keep the field present + // as a marker so the artifact still records that a refine prompt was configured. Some(ReplayRefineContext { - system_prompt: system_prompt.to_string(), + system_prompt: REDACTED_VALUE.to_string(), }) } @@ -1057,7 +1062,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 +1109,12 @@ mod tests { ) .expect("parse replay record"); + // refine_context is present (a non-empty refine prompt was configured) but + // the prompt text itself is redacted, matching the README redaction promise. + 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"), From 8c6c21ccefd7a669c0b75ba73f4cdd04a4f3a1ff Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Fri, 26 Jun 2026 19:52:33 +0100 Subject: [PATCH 05/16] fix(injection): treat whitespace-only text as empty for routing and injection (P2) non_empty_text (orchestrator + scoring) and inject_checked only rejected zero-length strings, so a whitespace-only output.final_text was preferred over a usable transcript.raw_text and typed as blank output into the focused app. STT and refine paths already use trim-based emptiness, so behavior was inconsistent. Align all three with trim-based emptiness: whitespace-only text counts as absent for routing/scoring (falling back to raw_text) and inject_checked rejects it. Add orchestrator regression tests for whitespace fallback and no-injection. Ref: .devana/...-P2-whitespace-only-text-injected.md --- src/lib.rs | 5 ++++- src/orchestrator.rs | 38 +++++++++++++++++++++++++++++++++++++- src/scoring.rs | 4 +++- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index e59b3db..cdb719a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -409,7 +409,10 @@ pub trait TextInjector: Send + Sync { async fn inject_unicode_text(&self, text: &str) -> MacosAdapterResult<()>; async fn inject_checked(&self, text: &str) -> MacosAdapterResult<()> { - if text.is_empty() { + // Reject whitespace-only text, not just zero-length, so a blank payload is + // never typed into the focused app. This matches the trim-based emptiness + // used by injection routing and the STT/refine pipeline. + if text.trim().is_empty() { return Err(MacosAdapterError::EmptyInjectionText); } self.inject_unicode_text(text).await diff --git a/src/orchestrator.rs b/src/orchestrator.rs index 0503643..ff7f6e2 100644 --- a/src/orchestrator.rs +++ b/src/orchestrator.rs @@ -82,7 +82,10 @@ fn route_envelope( } fn non_empty_text(text: &Option) -> Option<&str> { - text.as_deref().filter(|value| !value.is_empty()) + // Treat whitespace-only text as absent so routing falls back to a usable + // counterpart instead of injecting blank-looking output. STT and refine paths + // use the same trim-based emptiness, so this keeps routing consistent with them. + text.as_deref().filter(|value| !value.trim().is_empty()) } #[cfg(test)] @@ -154,6 +157,39 @@ 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/scoring.rs b/src/scoring.rs index 776ed96..728ef7c 100644 --- a/src/scoring.rs +++ b/src/scoring.rs @@ -380,7 +380,9 @@ fn looks_like_acronym(text: &str) -> bool { } fn non_empty_text(text: &Option) -> Option<&str> { - text.as_deref().filter(|value| !value.is_empty()) + // Match orchestrator/STT/refine: whitespace-only text counts as absent so a + // blank final_text does not suppress scoring of a usable raw transcript. + text.as_deref().filter(|value| !value.trim().is_empty()) } #[cfg(test)] From fa6b83ec47afac3fc04f8bfce6cf288066dad971 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Fri, 26 Jun 2026 19:54:36 +0100 Subject: [PATCH 06/16] docs(external-control): clarify that idle toggle is gated by start_recording_enabled (P2) The README implied external 'toggle' starts recording when idle without the start_recording_enabled opt-in, but the code (correctly) gates it like 'start'. Leaving toggle ungated would let any external agent bypass the microphone opt-in by sending 'toggle' instead of 'start', defeating the flag's security purpose, so the gate is intentional and the docs were wrong. Update the README and the action.rs doc comments to state that starting capture from idle is gated for both Start and Toggle (the tray path forces the gate on because a local human action is trusted). Add a regression assertion that an idle external toggle is Disabled without the opt-in. Ref: .devana/...-P2-external-toggle-start-gate.md --- README.md | 2 +- src/external_control/action.rs | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) 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/external_control/action.rs b/src/external_control/action.rs index 81b5def..7858dfa 100644 --- a/src/external_control/action.rs +++ b/src/external_control/action.rs @@ -7,7 +7,8 @@ pub(crate) enum ExternalControlAction { Start, /// Finish the active recording and run the pipeline. No-op when idle. Stop, - /// Start when idle, otherwise finish the active recording. + /// Start when idle (gated by `start_recording_enabled`, like `Start`), + /// otherwise finish the active recording. Toggle, /// Discard the active recording without running the pipeline. Cancel, @@ -23,8 +24,12 @@ pub(crate) enum ExternalControlOutcome { impl ExternalControlAction { /// Map an action onto the [`AppEvent`] appropriate for the current state. /// - /// Start requests are explicitly gated by config. `Noop` means the action - /// is allowed but has no state transition to perform. + /// Starting capture from idle is explicitly gated by `start_recording_enabled` + /// for both `Start` and `Toggle`, so an external agent cannot bypass the + /// microphone opt-in by sending `toggle` instead of `start`. (The tray click + /// path resolves with the gate forced on via `to_app_event`, since a local + /// human action is already trusted.) `Noop` means the action is allowed but has + /// no state transition to perform. pub(crate) fn resolve( self, state: AppState, @@ -198,6 +203,13 @@ mod tests { ExternalControlAction::Toggle.resolve(AppState::Idle, true), ExternalControlOutcome::Enabled(AppEvent::DoneTogglePressed) ); + // Security gate: an idle external toggle starts microphone capture, so it + // is gated by start_recording_enabled exactly like Start. Without the + // opt-in an external toggle cannot start capture. + 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!( From 9042d12ed6c07bdb5b1baa2bb7a41f53c75fcd96 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Fri, 26 Jun 2026 19:58:15 +0100 Subject: [PATCH 07/16] fix(url-scheme): honor url_scheme_enabled at runtime, not just at launch (P2) The muninn:// Apple Event handler was installed once at bootstrap when url_scheme_enabled was true, and dispatch forwarded every parsed verb without rechecking the flag. Disabling url_scheme_enabled via live config reload mutated in-memory config but left the handler active, so stop/toggle/cancel URLs from any local app still drove the runtime after an operator believed they were disabled. Gate URL dispatch on a runtime AtomicBool seeded from the launch config and refreshed on every ConfigReloaded event, and always install the handler so the flag is a real bidirectional runtime control surface. When disabled, parsed URLs are dropped with a log instead of reaching the worker. Ref: .devana/...-P2-url-scheme-disable-ignored-reload.md --- src/external_control.rs | 2 +- src/external_control/url_scheme.rs | 23 +++++++++++++++++++++++ src/runtime_shell.rs | 18 +++++++++++++++++- 3 files changed, 41 insertions(+), 2 deletions(-) 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/url_scheme.rs b/src/external_control/url_scheme.rs index 9e8030b..1e12a0a 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,20 @@ const AE_GET_URL: u32 = 0x4755_524C; // 'GURL' static PROXY: OnceLock> = OnceLock::new(); +/// Whether `muninn://` URLs should currently drive the runtime. The Apple Event +/// handler is installed once at launch, but `url_scheme_enabled` can change at +/// runtime via live config reload, so dispatch is gated on this flag rather than +/// on whether the handler is installed. Defaults to disabled until the launch +/// config is applied. +static URL_SCHEME_ENABLED: AtomicBool = AtomicBool::new(false); + +/// Update whether `muninn://` URLs are honored. Called with the launch config at +/// bootstrap and again on every live config reload so that disabling +/// `url_scheme_enabled` at runtime immediately stops external URL control. +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 +75,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/runtime_shell.rs b/src/runtime_shell.rs index e4c511d..c13666e 100644 --- a/src/runtime_shell.rs +++ b/src/runtime_shell.rs @@ -68,8 +68,18 @@ impl AppRuntime { // macOS delivers the launch GetURL Apple Event during // applicationWillFinishLaunching, earlier than StartCause::Init, so the // observer set up here must already be in place to catch cold-launch URLs. + // + // The handler is always installed, but URL dispatch is gated on the + // runtime url_scheme_enabled flag (seeded here and refreshed on every live + // config reload). This makes url_scheme_enabled a real runtime control + // surface: disabling it via reload immediately stops external URL control + // even though the Apple Event handler stays registered for the process + // lifetime. #[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()); } @@ -215,6 +225,12 @@ impl AppRuntime { } Event::UserEvent(UserEvent::ConfigReloaded(config)) => { current_config = (*config).clone(); + // Refresh the URL-scheme gate so disabling (or re-enabling) + // url_scheme_enabled via live reload takes effect immediately. + #[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); From 28bc0cf63a8d585bd8e1700dafd8be87d4a6a832 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Fri, 26 Jun 2026 20:03:54 +0100 Subject: [PATCH 08/16] fix(openai-streaming): drain all completed segments on finish, not just the first (P2) finish() broke out of the post-commit read loop as soon as the first completed transcription segment arrived, so multi-segment/multi-part transcripts were truncated to the first segment and an empty-then-real completion sequence dropped the real text. The accumulator already joins all completed entries, but finish() never consumed the rest of the stream. Drain segments instead: wait (bounded by the upstream finish timeout) for the first completion, then briefly drain additional segments within a short grace before finalizing. Reading until socket close is not viable here because the realtime session is persistent and the upstream finish timeout would abort and discard the transcript. Add regression tests for multi-segment accumulation and empty-first-completion recovery. Ref: .devana/...-P2-openai-streaming-truncates-segments.md --- src/streaming_transcription/openai.rs | 90 +++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 5 deletions(-) diff --git a/src/streaming_transcription/openai.rs b/src/streaming_transcription/openai.rs index 7a2ebd7..e4c50a7 100644 --- a/src/streaming_transcription/openai.rs +++ b/src/streaming_transcription/openai.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use async_trait::async_trait; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use futures_util::{SinkExt, StreamExt}; @@ -25,6 +27,10 @@ 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; +/// After the first completed transcription segment arrives post-commit, wait at +/// most this long for additional segments/content parts before finalizing, so a +/// single-segment utterance is not delayed by the full streaming finish timeout. +const POST_COMPLETION_DRAIN_GRACE: Duration = Duration::from_millis(300); #[derive(Debug, Clone, Copy, Default)] pub struct OpenAiStreamingTranscriptionProvider; @@ -275,14 +281,37 @@ where )) .await?; - while let Some(message) = self.socket.next().await? { + // Drain completed transcription segments after the commit. The realtime + // session is persistent and does not close the socket once an utterance is + // transcribed, so we cannot read until close (the upstream finish timeout + // would abort and discard the transcript). Instead, wait for the first + // completed segment, then briefly drain any further segments/content parts + // that arrive back-to-back before finalizing. Stopping on the first + // completion would truncate multi-segment transcripts. + loop { + let message = if self.transcript.completed_count() == 0 { + // No completion yet: wait for the next message. Transcription + // latency is bounded by the upstream streaming finish timeout. + match self.socket.next().await? { + Some(message) => message, + None => break, + } + } else { + // At least one completion captured: only wait a short grace for + // additional segments so a single-segment utterance is not delayed + // by the full finish timeout. + match tokio::time::timeout(POST_COMPLETION_DRAIN_GRACE, self.socket.next()).await { + Ok(result) => match result? { + Some(message) => message, + None => break, + }, + Err(_elapsed) => 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 { - break; - } } Message::Binary(_) | Message::Pong(_) | Message::Frame(_) => {} Message::Ping(payload) => { @@ -852,6 +881,57 @@ 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":"conversation.item.input_audio_transcription.completed","item_id":"item_001","content_index":0,"transcript":"first segment"}"# + .to_string(), + ), + Message::Text( + r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"item_002","content_index":0,"transcript":"second segment"}"# + .to_string(), + ), + ], + ); + + let outcome = Box::new(OpenAiStreamingSession::new(socket, request())) + .finish() + .await + .expect("session should finalize"); + + // Regression: finish() must not stop after the first completed segment. + assert_eq!(outcome.raw_text.as_deref(), Some("first segment second segment")); + } + + #[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":"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 session_replies_to_ping_while_finishing() { let state = Arc::new(Mutex::new(FakeWebSocketState::default())); From a147f7f17f12608fe37cef43432b1a1b23d8620b Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Fri, 26 Jun 2026 20:08:47 +0100 Subject: [PATCH 09/16] fix(runner): bound external-step stdin write and drain stdout concurrently (P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_command wrote the full stdin payload and awaited shutdown before spawning the stdout/stderr drains and before arming timeout(timeout_budget, child.wait()). A child that emits output while reading (fills its stdout pipe and stops reading stdin) or that never reads stdin to EOF blocked the parent's write_all forever, and the timeout safety net was never reached — freezing the dictation pipeline. Spawn the stdout/stderr readers before writing stdin so the child stays unblocked, and drive the write/shutdown together with child.wait() under one timeout_budget (WritePhaseError maps the failing phase to its CommandErrorKind). Add regression tests for the large-stdin round-trip and the never-reads-stdin timeout. Mark the devana report fixed. Ref: .devana/...-P2-external-step-stdin-write-deadlock.md --- ...Z-P2-external-step-stdin-write-deadlock.md | 92 +++++++++++++ src/runner/transport.rs | 127 +++++++++++++++--- 2 files changed, 197 insertions(+), 22 deletions(-) create mode 100644 .devana/20260625T091238Z-P2-external-step-stdin-write-deadlock.md diff --git a/.devana/20260625T091238Z-P2-external-step-stdin-write-deadlock.md b/.devana/20260625T091238Z-P2-external-step-stdin-write-deadlock.md new file mode 100644 index 0000000..196c7e7 --- /dev/null +++ b/.devana/20260625T091238Z-P2-external-step-stdin-write-deadlock.md @@ -0,0 +1,92 @@ +DEVANA-FINDING: v1 +Priority: P2 | Confidence: high | Security-sensitive: no | Status: fixed +Location: src/runner/transport.rs:79-120 | Slug: external-step-stdin-write-deadlock + +# External pipeline step can hang unbounded; timeout does not cover the stdin write + +## Finding + +`run_command` writes the entire stdin payload to the child, awaits the write and +`shutdown` to completion, and only *afterwards* spawns the stdout/stderr drains +and wraps `child.wait()` in `timeout(timeout_budget, ...)`. The write phase at +lines 79-97 is not covered by any timeout and runs while nothing is draining the +child's stdout. A child that does not consume all of stdin (or that fills its own +stdout pipe before reading stdin) blocks the parent's `write_all` forever, and the +`timeout_budget` safety net at line 120 is never reached. + +## Violated Invariant Or Contract + +A configured pipeline step must complete or fail within `timeout_budget`. The +`timeout_budget` parameter exists precisely to bound step execution (see +`specs/11-runtime-resource-bounds`), but it only guards `child.wait()`, not the +`stdin.write_all`/`shutdown` that precede it. + +## Oracle + +Two source-of-truth signals: (1) the explicit `timeout(timeout_budget, child.wait())` +at line 120 establishes that step execution is intended to be time-bounded; the +write phase escaping it is an inconsistency. (2) Standard OS-pipe back-pressure: +a parent that writes more than the pipe buffer (~64 KB) to a child while not +concurrently draining the child's stdout will deadlock — the canonical reason +readers are normally spawned *before* the blocking write. + +## Counterexample + +Configure any external pipeline step (a `TextFilter` command, or one running in +`io_mode = envelope_json`) whose command either (a) does not read stdin to EOF, or +(b) starts writing to stdout as it reads. Feed it an envelope/text larger than the +OS pipe buffer (a long dictation in `envelope_json` mode serializes the transcript +plus trace, easily exceeding 64 KB). The child fills its stdout pipe and stops +reading stdin (case b), or never drains stdin (case a). The parent blocks at +`stdin.write_all(stdin_bytes)` (line 80). Because the stdout reader is not spawned +until line 115 and the timeout is not armed until line 120, the await never +returns and the step hangs indefinitely. + +## Why It Might Matter + +The dictation pipeline thread blocks permanently on a single misbehaving or +slow external step, so a recording never completes and the app appears frozen +with no error and no timeout firing. `kill_on_drop(true)` (line 61) does not help +because the child is never dropped during the hung write. + +## Proof + +Control-flow / ordering trace in `run_command`: +- line 80: `stdin.write_all(stdin_bytes).await` — blocking, no timeout, no concurrent stdout drain. +- line 90: `stdin.shutdown().await` — same. +- lines 115-118: stdout/stderr `read_to_end_capped` tasks spawned — only now is stdout drained. +- line 120: `timeout(timeout_budget, child.wait())` — the only time bound, reached only after the write already returned. + +## Counterevidence Checked + +- `kill_on_drop(true)` (line 61): does not fire during the hung write; the child is not dropped. +- `max_stdout_bytes` cap in `read_to_end_capped`: irrelevant while no reader is running. +- No `tokio::select!` or `timeout` wraps lines 79-97; confirmed by reading the full function. +- Reachability requires a user-configured external step; default-only pipelines without external commands are unaffected (hence P2, not P1). + +## Suggested Next Step + +Spawn the stdout/stderr drains before the stdin write, and wrap the write/shutdown +in the same `timeout_budget` (e.g. drive write and `child.wait()` under one +`tokio::select!`/`timeout`) so a stalled child cannot hang the pipeline. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `Status: ...` and the final `DEVANA-SUMMARY:` status. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Add dated notes below with the evidence checked. + +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-26: fixed. Reordered `run_command` to spawn the stdout/stderr drain + tasks before writing stdin, and moved the stdin `write_all`/`shutdown` together + with `child.wait()` into a single `timeout(timeout_budget, ...)` future (new + `WritePhaseError` enum maps the failing phase back to the right error kind). + This removes both deadlock paths: a child that emits output while reading is now + drained concurrently, and a child that never reads stdin to EOF is bounded by + `timeout_budget` instead of hanging. Added two regression tests in + `transport.rs`: `drains_stdout_while_writing_large_stdin_without_deadlock` + (512 KB round-trip through `cat`) and `times_out_when_child_never_reads_stdin` + (512 KB into `sleep 5` hits the 200 ms timeout). Both pass. + +DEVANA-KEY: src/runner/transport.rs:79-120 | P2 | external-step-stdin-write-deadlock +DEVANA-SUMMARY: Status=fixed | P2 high src/runner/transport.rs:79-120 - External step stdin write runs outside timeout_budget with no concurrent stdout drain, so a misbehaving step deadlocks the pipeline indefinitely. diff --git a/src/runner/transport.rs b/src/runner/transport.rs index 21404ec..870787b 100644 --- a/src/runner/transport.rs +++ b/src/runner/transport.rs @@ -35,6 +35,14 @@ pub(super) enum CommandErrorKind { Timeout, } +/// Which phase of the bounded write/wait future failed, so the outer timeout can +/// map it back to the right [`CommandErrorKind`]. +enum WritePhaseError { + Write(io::Error), + Close(io::Error), + Wait(io::Error), +} + #[derive(Debug)] pub(super) struct CommandError { pub(super) kind: CommandErrorKind, @@ -76,26 +84,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(), @@ -112,14 +100,55 @@ pub(super) async fn run_command( stderr: CapturedOutput::default(), })?; + // Spawn the stdout/stderr drains BEFORE writing stdin. A child that emits + // output while consuming input would otherwise fill its stdout pipe, stop + // reading stdin, and deadlock the parent's write_all. Draining concurrently + // keeps the child unblocked so the write can make progress. let stdout_reader = tokio::spawn(async move { read_to_end_capped(stdout, max_stdout_bytes).await }); 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 { + // Drive the stdin write/shutdown and child.wait() under a single + // timeout_budget. Previously the write phase ran outside any timeout and with + // no concurrent stdout drain, so a child that never reads stdin to EOF (or + // stalls) blocked write_all forever and the timeout was never reached. + 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)) => { + Ok(Err(WritePhaseError::Write(source))) => { + let (stderr, cleanup_notes) = + cleanup_after_command_failure(&mut child, stdout_reader, stderr_reader).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(&mut child, stdout_reader, stderr_reader).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(&mut child, stdout_reader, stderr_reader).await; return Err(CommandError { @@ -254,3 +283,57 @@ fn format_command_error_details(primary: String, cleanup_notes: Vec) -> format!("{primary}; {}", cleanup_notes.join("; ")) } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + // Far larger than any OS pipe buffer (~64 KB), so a child that reads and writes + // concurrently, or that never reads stdin, exposes back-pressure deadlocks. + 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() { + // `cat` echoes stdin to stdout. If stdin were fully written before the + // stdout reader was spawned, cat would fill its stdout pipe, stop reading + // stdin, and the parent's write_all would 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() { + // `sleep` never reads stdin, so a payload larger than the pipe buffer blocks + // the parent's write_all. The write phase must now be bounded by + // timeout_budget instead of hanging forever. + 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); + } +} From 57cde2c0ddfb97c7e1af423558dc4a0f3a4262dc Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Fri, 26 Jun 2026 20:11:05 +0100 Subject: [PATCH 10/16] fix(google-stt): aggregate all result segments instead of keeping the first (P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extract_google_transcript_text flattened results[]->alternatives[]->transcript and took .next(), returning only the first segment's first alternative. Google's speech:recognize returns one results entry per consecutive audio segment for longer audio, so longer dictations were silently truncated to roughly the first segment with no error. Aggregate over all results, taking each result's first alternative and concatenating them (segments carry their own leading spaces), then trim — matching the Whisper and Deepgram adapters. Add regression tests for multi-segment concatenation and first-alternative selection. Mark the devana report fixed. Ref: .devana/...-P2-google-batch-multisegment-truncation.md --- ...P2-google-batch-multisegment-truncation.md | 86 +++++++++++++++++++ src/stt_google_tool.rs | 44 ++++++++-- 2 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 .devana/20260625T091239Z-P2-google-batch-multisegment-truncation.md diff --git a/.devana/20260625T091239Z-P2-google-batch-multisegment-truncation.md b/.devana/20260625T091239Z-P2-google-batch-multisegment-truncation.md new file mode 100644 index 0000000..c8151a4 --- /dev/null +++ b/.devana/20260625T091239Z-P2-google-batch-multisegment-truncation.md @@ -0,0 +1,86 @@ +DEVANA-FINDING: v1 +Priority: P2 | Confidence: high | Security-sensitive: no | Status: fixed +Location: src/stt_google_tool.rs:595-606 | Slug: google-batch-multisegment-truncation + +# Google batch adapter keeps only the first transcript segment, dropping the rest + +# Finding + +`extract_google_transcript_text` flattens `results[] -> alternatives[] -> +transcript` into a single iterator and takes `.next()`, returning exactly one +string. Google's `speech:recognize` returns one `results` entry per consecutive +audio segment for longer audio, with the full transcript being the concatenation +of `results[i].alternatives[0].transcript`. Taking `.next()` keeps only the first +segment's first alternative and silently discards every later segment. + +## Violated Invariant Or Contract + +The shared STT contract is "store the full transcript in `transcript.raw_text`." +The sibling adapters honor this by aggregating all segments: Whisper concatenates +all segments (`stt_whisper_cpp_tool.rs` `collect_transcript_text`), Deepgram joins +all final segments (`stt_deepgram_tool.rs`). The design note +`specs/12-google-live-stt/design.md` references `results[].alternatives[].transcript` +with a plural `results[]`, implying all results must be read. + +## Oracle + +Differential against the neighboring adapter implementations (Whisper/Deepgram +both aggregate over all segments) and against Google's documented response shape +(multiple `results`, one per consecutive segment). A correct implementation joins +all segment transcripts; this one returns only the first via `.next()`. + +## Counterexample + +Response body: +`{"results":[{"alternatives":[{"transcript":"hello world"}]},{"alternatives":[{"transcript":" goodbye now"}]}]}` + +The iterator `results -> flatten -> alternatives -> flatten -> transcript -> +.next()` yields `"hello world"` and discards `" goodbye now"`. `raw_text` becomes +`"hello world"` instead of `"hello world goodbye now"`. + +## Why It Might Matter + +For any utterance long enough that Google splits the response into more than one +`results` entry, the persisted/injected transcript is truncated to roughly the +first segment with no error surfaced. This is silent data loss on the product's +core function (transcription accuracy) and grows worse with longer dictation. + +## Proof + +Read of `src/stt_google_tool.rs:595-606`. The chained `.flatten()` collapses all +`results` entries and all their `alternatives` into one stream; `.next()` returns +a single `&str`. There is no later concatenation. The guard at line 608 only +controls empty-vs-error classification and does not aggregate. + +## Counterevidence Checked + +- Existing tests feed only single-`results` bodies (around lines 954-967, 1003), so multi-segment truncation is untested, not prevented. +- `.next()` is unambiguously single-element; no surrounding join collects the rest. +- The known excluded finding covers OpenAI *streaming* truncation (`streaming_transcription/openai.rs:278-285`); this is the separate Google *batch* adapter. +- Short single-segment utterances yield one `results` entry and are unaffected, which is why this surfaces as a length-dependent truncation (P2) rather than a total failure. + +## Suggested Next Step + +Replace `.next()` with an aggregation over all `results` entries, joining each +`results[i].alternatives[0].transcript` (matching the Whisper/Deepgram behavior), +then trim the joined string. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `Status: ...` and the final `DEVANA-SUMMARY:` status. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Add dated notes below with the evidence checked. + +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-26: fixed. Rewrote `extract_google_transcript_text` to aggregate over + all `results` entries, taking each result's first alternative + (`alternatives[0].transcript`) and concatenating them, then trimming the joined + string — matching the Whisper/Deepgram aggregation behavior. Segments carry their + own leading spaces, so concatenation without a separator reproduces Google's + natural spacing. Added regression tests + `response_json_concatenates_multiple_result_segments` (two segments → + "hello world goodbye now") and `response_json_uses_first_alternative_per_result`. + Both pass alongside the existing single-segment/empty/missing-results tests. + +DEVANA-KEY: src/stt_google_tool.rs:595-606 | P2 | google-batch-multisegment-truncation +DEVANA-SUMMARY: Status=fixed | P2 high src/stt_google_tool.rs:595-606 - Google batch adapter takes .next() over flattened results, dropping every transcript segment after the first and truncating longer dictations. diff --git a/src/stt_google_tool.rs b/src/stt_google_tool.rs index 15b9db0..b080c5a 100644 --- a/src/stt_google_tool.rs +++ b/src/stt_google_tool.rs @@ -592,18 +592,27 @@ fn extract_google_transcript_text(body: &[u8]) -> Result { ) })?; + // Google's speech:recognize returns one `results` entry per consecutive audio + // segment for longer audio; the full transcript is the concatenation of each + // segment's top alternative. Aggregate all results (taking alternatives[0] per + // result) rather than keeping only the first segment, matching the Whisper and + // Deepgram adapters. Segment transcripts carry their own leading spaces, so we + // concatenate without a separator and trim the joined string. let transcript = value .get("results") .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() - .map(str::trim) - .unwrap_or_default() - .to_string(); + .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) + }) + .collect::(); + let transcript = transcript.trim().to_string(); if !transcript.is_empty() || value.get("results").is_some() { return Ok(transcript); @@ -959,6 +968,27 @@ mod tests { assert_eq!(transcript, "hello from google"); } + #[test] + fn response_json_concatenates_multiple_result_segments() { + // Regression: longer audio returns one `results` entry per segment; the + // full transcript is their concatenation, not just the first segment. + 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() { + // Only the top alternative of each result contributes to the transcript. + 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":[]}"#) From 407fb85dbba79fdb6e69b8528ab008b4544752a6 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Fri, 26 Jun 2026 20:16:17 +0100 Subject: [PATCH 11/16] fix(streaming): surface websocket error close frames as provider failures (P3) Both the Deepgram and OpenAI streaming backends finalized with 'Message::Close(_) => break' and then returned into_outcome(), which builds an empty *success* outcome when no final text was seen. A server-initiated error close (auth rejected, quota/credits exhausted, policy/internal fault) was therefore reported to the user as 'no speech detected' instead of the real error, hiding actionable failures like an expired API key. Inspect the close frame: a non-benign code (not 1000 Normal / 1001 Going Away, and a frame is present) is captured as StreamingTranscriptionError::failed with the code and reason. The error is returned only when no usable final text was produced, so a transcript completed before a trailing error close still succeeds. Add regression tests for both backends. Mark the devana report fixed. Ref: .devana/...-P3-streaming-error-close-as-empty.md --- ...1240Z-P3-streaming-error-close-as-empty.md | 88 +++++++++++++++++++ src/streaming_transcription/deepgram.rs | 81 ++++++++++++++++- src/streaming_transcription/openai.rs | 53 ++++++++++- 3 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 .devana/20260625T091240Z-P3-streaming-error-close-as-empty.md diff --git a/.devana/20260625T091240Z-P3-streaming-error-close-as-empty.md b/.devana/20260625T091240Z-P3-streaming-error-close-as-empty.md new file mode 100644 index 0000000..e64b7a1 --- /dev/null +++ b/.devana/20260625T091240Z-P3-streaming-error-close-as-empty.md @@ -0,0 +1,88 @@ +DEVANA-FINDING: v1 +Priority: P3 | Confidence: medium | Security-sensitive: no | Status: fixed +Location: src/streaming_transcription/deepgram.rs:233, src/streaming_transcription/openai.rs:291 | Slug: streaming-error-close-as-empty + +# Server error Close frame is reported as an empty transcript instead of a failure + +## Finding + +Both websocket streaming backends finalize with `Message::Close(_) => break`, +discarding the close frame's code and reason, then return +`self.transcript.into_outcome()`. When the stream ended without any final text, +`into_outcome` builds an empty (success) outcome. A server-initiated *error* close +(auth rejected, quota exceeded, internal/policy error) is therefore classified as +an empty transcript rather than a provider failure. + +## Violated Invariant Or Contract + +A connection torn down by the server with an error close code is a failure and +should map to `StreamingTranscriptionError::failed(...)` (the trait's `finish` +returns `Result`), not to +an `Ok` empty outcome carrying an `EmptyTranscript` diagnostic. + +## Oracle + +Same-file contract mismatch: the identical failure delivered as a JSON text +message routes through `handle_text_message` and yields `Err` — +`Some("Error") => Err(deepgram_provider_error(&value))` at +`src/streaming_transcription/deepgram.rs:340` (OpenAI mirrors this at openai.rs:402). +A failure conveyed via a Close frame should be classified equivalently, but the +`Message::Close(_)` arm binds nothing and breaks into the success path. + +## Counterexample + +The provider rejects the stream with a close frame (e.g. Deepgram code 1008/4001, +reason "insufficient_credits") rather than an `{"type":"Error"}` text message. The +`Close(_)` arm discards `frame.code`/`frame.reason`, breaks, and `into_outcome()` +returns an empty success outcome. The controller +(`src/streaming_transcription.rs`) sees `Ok` with no final text and surfaces +`EmptyTranscript`, masking the real cause and falling back to recorded +transcription with a misleading diagnostic. + +## Why It Might Matter + +A real provider error (auth/quota/server fault) is reported to the user as "no +speech detected" instead of an error, hiding actionable failures (e.g. an expired +API key) behind an empty-transcript message. Impact is diagnostic correctness, not +data loss, since recorded-transcription fallback still runs — hence P3. + +## Proof + +Contract mismatch across two arms of the same match: +- `deepgram.rs:340` / `openai.rs:402`: `"Error"` text message -> `Err(... failed ...)`. +- `deepgram.rs:233` / `openai.rs:291`: `Message::Close(_) => break` -> `Ok(into_outcome())`, which builds an empty outcome when no final text was seen. +The close frame (and its code/reason) is available at the break point and is deliberately discarded. + +## Counterevidence Checked + +- Normal/benign closes also arrive as a Close frame, so breaking is correct for them; the misclassification only bites on error closes (medium confidence on reachability — providers often send a text `Error` first). +- Google streaming is RPC-based and unaffected. +- `into_outcome` was confirmed to never produce a `Failed` variant; it only builds empty/produced outcomes, so the close path cannot currently surface the error. + +## Suggested Next Step + +Inspect the close frame: when the code/reason indicates an error (non-1000, or a +provider error range), return `StreamingTranscriptionError::failed(...)` instead of +breaking into the empty-success path. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `Status: ...` and the final `DEVANA-SUMMARY:` status. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Add dated notes below with the evidence checked. + +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-26: fixed. Both backends now inspect the close frame in the + `Message::Close` arm. A frame with a non-benign code (anything other than 1000 + Normal / 1001 Going Away, or no frame at all) is captured as a + `StreamingTranscriptionError::failed(...)` carrying the close code and reason + (codes `deepgram_closed_with_error` / `openai_realtime_closed_with_error`). + After the loop, the error is returned only when no usable final text was + produced, so a transcript completed before a trailing error close is still + returned as success. Added regression tests: deepgram + `finish_reports_error_close_frame_as_failure` and + `finish_keeps_transcript_when_error_close_follows_final_text`, and openai + `finish_reports_error_close_frame_as_failure`. All streaming tests pass (52). + +DEVANA-KEY: src/streaming_transcription/deepgram.rs:233,src/streaming_transcription/openai.rs:291 | P3 | streaming-error-close-as-empty +DEVANA-SUMMARY: Status=fixed | P3 medium src/streaming_transcription/deepgram.rs:233 - Websocket error Close frames are swallowed into an empty-success outcome, masking provider auth/quota failures as empty transcripts. diff --git a/src/streaming_transcription/deepgram.rs b/src/streaming_transcription/deepgram.rs index a9e62ec..ed49b97 100644 --- a/src/streaming_transcription/deepgram.rs +++ b/src/streaming_transcription/deepgram.rs @@ -20,6 +20,7 @@ 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"}"#; @@ -221,6 +222,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 +232,37 @@ where Message::Ping(payload) => { self.socket.send(Message::Pong(payload)).await?; } - Message::Close(_) => break, + Message::Close(frame) => { + // A server-initiated error close (e.g. 1008/4xxx + // "insufficient_credits", auth/policy failure) must be a provider + // failure, not an empty-success outcome reported as "no speech + // detected". Benign closes (1000 Normal, 1001 Going Away, or no + // frame) just end the loop. + 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(); + // Prefer a transcript produced before an error close; only surface the close + // as a failure when there is no usable final text. + 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/openai.rs b/src/streaming_transcription/openai.rs index e4c50a7..d2d2e6c 100644 --- a/src/streaming_transcription/openai.rs +++ b/src/streaming_transcription/openai.rs @@ -27,6 +27,7 @@ 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"; /// After the first completed transcription segment arrives post-commit, wait at /// most this long for additional segments/content parts before finalizing, so a /// single-segment utterance is not delayed by the full streaming finish timeout. @@ -288,6 +289,7 @@ where // completed segment, then briefly drain any further segments/content parts // that arrive back-to-back before finalizing. Stopping on the first // completion would truncate multi-segment transcripts. + let mut error_close: Option = None; loop { let message = if self.transcript.completed_count() == 0 { // No completion yet: wait for the next message. Transcription @@ -317,12 +319,37 @@ where Message::Ping(payload) => { self.socket.send(Message::Pong(payload)).await?; } - Message::Close(_) => break, + Message::Close(frame) => { + // A server-initiated error close (auth/quota/policy) must be a + // provider failure, not an empty-success outcome that would be + // reported to the user as "no speech detected". Benign closes + // (1000 Normal, 1001 Going Away, or no frame) just end the loop. + 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(); + // Prefer a transcript that was produced before an error close; only surface + // the close as a failure when there is no usable final text. + match error_close { + Some(error) if !outcome.has_final_text() => Err(error), + _ => Ok(outcome), + } } async fn cancel(mut self: Box) { @@ -932,6 +959,28 @@ api_key = "config-key" 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())); From 19b1616cbc0fef36e259abd3dd8b8ef016381882 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Fri, 26 Jun 2026 20:17:39 +0100 Subject: [PATCH 12/16] docs(devana): add input bug-hunt reports addressed on this branch The P0/P1/P2 reports whose fixes did not modify the report file itself; the three reports with an Agent Handoff section were committed alongside their fixes with status updated to fixed. --- ...5T083533Z-P0-apple-speech-helper-toctou.md | 46 +++++++++++++++++++ ...P1-accessibility-grant-loses-transcript.md | 46 +++++++++++++++++++ ...-P1-replay-audio-deleted-before-persist.md | 45 ++++++++++++++++++ ...6Z-P1-replay-prompt-leak-refine-context.md | 45 ++++++++++++++++++ ...83537Z-P2-whitespace-only-text-injected.md | 44 ++++++++++++++++++ ...5T083538Z-P2-external-toggle-start-gate.md | 46 +++++++++++++++++++ ...9Z-P2-url-scheme-disable-ignored-reload.md | 43 +++++++++++++++++ ...-P2-openai-streaming-truncates-segments.md | 46 +++++++++++++++++++ 8 files changed, 361 insertions(+) create mode 100644 .devana/20260625T083533Z-P0-apple-speech-helper-toctou.md create mode 100644 .devana/20260625T083534Z-P1-accessibility-grant-loses-transcript.md create mode 100644 .devana/20260625T083535Z-P1-replay-audio-deleted-before-persist.md create mode 100644 .devana/20260625T083536Z-P1-replay-prompt-leak-refine-context.md create mode 100644 .devana/20260625T083537Z-P2-whitespace-only-text-injected.md create mode 100644 .devana/20260625T083538Z-P2-external-toggle-start-gate.md create mode 100644 .devana/20260625T083539Z-P2-url-scheme-disable-ignored-reload.md create mode 100644 .devana/20260625T083540Z-P2-openai-streaming-truncates-segments.md diff --git a/.devana/20260625T083533Z-P0-apple-speech-helper-toctou.md b/.devana/20260625T083533Z-P0-apple-speech-helper-toctou.md new file mode 100644 index 0000000..bdef103 --- /dev/null +++ b/.devana/20260625T083533Z-P0-apple-speech-helper-toctou.md @@ -0,0 +1,46 @@ +DEVANA-FINDING: v1 +Priority: P0 | Confidence: high | Security-sensitive: yes | Status: open +Location: src/stt_apple_speech_tool.rs:615-653 | Slug: apple-speech-helper-toctou + +# Apple Speech helper skips integrity check after first materialization + +## Finding + +The embedded Apple Speech helper binary is written to a predictable path under the user temp directory and integrity-checked only on first materialization. After `HELPER_PATH` is cached in a `OnceLock`, later transcriptions return the cached path without re-running `helper_needs_refresh()`, so a same-user attacker can replace the on-disk helper and have Muninn execute it on the next dictation. + +## Violated Invariant Or Contract + +A binary executed with envelope stdin (including `audio.wav_path`) should match the embedded bytes Muninn shipped, on every invocation. + +## Oracle + +`materialize_helper_binary()` fast-path at lines 619-621 and 630-632; `helper_needs_refresh()` only runs before first `HELPER_PATH.set()`; `invoke_apple_speech_helper()` spawns `Command::new(&helper_path)` at line 334. + +## Counterexample + +1. User runs Muninn and completes one Apple Speech transcription; helper is materialized at `$TMPDIR/muninn/embedded-tools/apple-speech-transcriber---`. +2. Same-user malware replaces that file with a trojan binary (path is predictable from version constants). +3. User dictates again with Apple Speech in the route. +4. `materialize_helper_binary()` returns the cached path without re-reading bytes. +5. Muninn spawns the trojan with serialized envelope JSON on stdin. + +## Why It Might Matter + +Arbitrary code execution as the Muninn user during normal dictation, with access to temp WAV paths and envelope contents. No malicious pipeline config or external-control interaction is required. + +## Proof + +Control-flow trace: first transcription → `helper_needs_refresh` + `write_helper_atomically` → `HELPER_PATH.set` → subsequent calls skip refresh → `Command::new(helper_path).spawn()`. + +Counterexample value: replaced bytes at predictable `helper_output_path()` after first successful materialization. + +## Counterevidence Checked + +`write_helper_atomically` stages via a temp file and `ensure_helper_permissions` sets executable bits, but neither re-validates content on later runs. `HELPER_INIT` mutex only prevents concurrent first init, not post-cache tampering. + +## Suggested Next Step + +Re-run `helper_needs_refresh()` (or equivalent signature check) before every spawn, or execute from a non-user-writable location with immutable permissions after install. + +DEVANA-KEY: src/stt_apple_speech_tool.rs:615-653 | P0 | apple-speech-helper-toctou +DEVANA-SUMMARY: P0 high src/stt_apple_speech_tool.rs:615-653 - Cached Apple Speech helper path skips re-verification, allowing same-user replacement and arbitrary code execution on later transcriptions. \ No newline at end of file diff --git a/.devana/20260625T083534Z-P1-accessibility-grant-loses-transcript.md b/.devana/20260625T083534Z-P1-accessibility-grant-loses-transcript.md new file mode 100644 index 0000000..65d465b --- /dev/null +++ b/.devana/20260625T083534Z-P1-accessibility-grant-loses-transcript.md @@ -0,0 +1,46 @@ +DEVANA-FINDING: v1 +Priority: P1 | Confidence: high | Security-sensitive: no | Status: open +Location: src/runtime_pipeline.rs:87-91,143-144 | Slug: accessibility-grant-loses-transcript + +# Accessibility grant during first injection silently discards completed transcript + +## Finding + +When the user grants Accessibility during the first injection attempt, `should_abort_injection` returns true even though permissions are now granted. `process_and_inject` returns `Ok(())` without injecting, then unconditionally transitions to `InjectionFinished` and deletes the temp WAV. The log tells the user to "retry the injection action," but no retry path exists for the already-processed utterance. + +## Violated Invariant Or Contract + +A successful pipeline with injectable text should either inject the text or surface a recoverable failure. Aborting after the user grants the required permission should not discard the transcript and recording artifact. + +## Oracle + +`should_abort_injection` at `runtime_permissions.rs:202-208` aborts when `requested_accessibility` is true even if `ensure_injection_allowed` succeeds; unit test `injection_aborts_after_accessibility_prompt_even_when_now_granted` in `main.rs:558-562` encodes this branch. `process_and_inject` always calls `cleanup_recording_file` after `InjectionFinished` (`runtime_pipeline.rs:143-144`). + +## Counterexample + +1. User dictates; pipeline completes with `route.target.text() == Some("cargo test -q")`. +2. Accessibility was `NotDetermined`; `refresh_injection_permissions_for_user_action` prompts and user grants. +3. `should_abort_injection(all_granted(), true)` returns true. +4. Function returns `Ok(())` at line 91 without calling `inject_checked`. +5. State moves to Idle; WAV deleted. User must re-dictate the entire utterance. + +## Why It Might Matter + +First-time macOS setup is common (README documents Accessibility prompt on first injection). Users who grant access during that prompt lose a fully transcribed utterance with no indicator that re-dictation is required. + +## Proof + +Control-flow trace: `ProcessingFinished` → permission refresh with prompt → `should_abort_injection` true → early `return Ok(())` → `InjectionFinished` → `cleanup_recording_file`. + +Cross-entry mismatch: recording-start abort happens before pipeline work; injection abort happens after pipeline success, so the retry-gesture pattern has different data-loss impact. + +## Counterevidence Checked + +`MissingCredentials` indicator path exists for empty injectable text, not for this permission branch. No queue or retained envelope for a follow-up injection gesture. + +## Suggested Next Step + +After a successful Accessibility grant in the same interaction, proceed with `inject_checked` instead of aborting, or retain the injection text and expose an explicit retry that does not require re-recording. + +DEVANA-KEY: src/runtime_pipeline.rs:87-91,143-144 | P1 | accessibility-grant-loses-transcript +DEVANA-SUMMARY: P1 high src/runtime_pipeline.rs:87-91,143-144 - Granting Accessibility on first injection aborts silently and deletes the WAV, losing a completed transcript despite the retry log message. \ No newline at end of file diff --git a/.devana/20260625T083535Z-P1-replay-audio-deleted-before-persist.md b/.devana/20260625T083535Z-P1-replay-audio-deleted-before-persist.md new file mode 100644 index 0000000..310d504 --- /dev/null +++ b/.devana/20260625T083535Z-P1-replay-audio-deleted-before-persist.md @@ -0,0 +1,45 @@ +DEVANA-FINDING: v1 +Priority: P1 | Confidence: high | Security-sensitive: no | Status: open +Location: src/runtime_pipeline.rs:76,143-144 | Slug: replay-audio-deleted-before-persist + +# Replay audio retention races temp WAV deletion + +## Finding + +Replay persistence is enqueued asynchronously via `try_send` to a background worker, but the runtime worker deletes the temp WAV synchronously immediately after injection finishes. When `replay_detail = "full_debug"` and `replay_retain_audio = true`, the background worker often finds the source file missing and silently skips audio retention. + +## Violated Invariant Or Contract + +When replay audio retention is enabled, the persisted artifact should include the recorded audio for the utterance that just completed. + +## Oracle + +`replay_persist.enqueue` is non-blocking (`replay_dispatch.rs:124-131`); `cleanup_recording_file` runs on the worker thread at `runtime_pipeline.rs:143-144` before persist is guaranteed; `retain_audio_if_available` returns `Ok(None)` when `!recorded.wav_path.exists()` (`replay.rs:495-497`). + +## Counterexample + +1. User enables `replay_enabled`, `replay_detail = "full_debug"`, `replay_retain_audio = true`. +2. Utterance completes; `enqueue` succeeds but worker is still processing prior artifacts or not yet scheduled. +3. Main thread reaches `cleanup_recording_file(&recorded.wav_path)`. +4. Worker later calls `retain_audio_if_available`; file is gone → no `audio.*` artifact, no hard error surfaced to the user. + +## Why It Might Matter + +Full-debug replay is explicitly for diagnosing utterance issues; missing audio undermines the primary debugging value of retained recordings, including on normal shutdown paths (not only crash). + +## Proof + +Dataflow trace: `RecordedAudio.wav_path` → async enqueue → synchronous delete → `retain_audio_if_available` miss. + +Related: queue capacity 8 drops entire persist requests on overflow (`replay_dispatch.rs:126-131`), compounding artifact loss under burst load. + +## Counterevidence Checked + +Hard-link/copy logic in `retain_audio_file` works when the source still exists. Synchronous persist is not used. No ordering guarantee between enqueue and cleanup. + +## Suggested Next Step + +Retain or copy audio before deleting the temp WAV (or block cleanup until persist acknowledges audio retention). + +DEVANA-KEY: src/runtime_pipeline.rs:76,143-144 | P1 | replay-audio-deleted-before-persist +DEVANA-SUMMARY: P1 high src/runtime_pipeline.rs:76,143-144 - Async replay enqueue races synchronous WAV cleanup, so full-debug audio retention is often silently dropped. \ No newline at end of file diff --git a/.devana/20260625T083536Z-P1-replay-prompt-leak-refine-context.md b/.devana/20260625T083536Z-P1-replay-prompt-leak-refine-context.md new file mode 100644 index 0000000..6e48682 --- /dev/null +++ b/.devana/20260625T083536Z-P1-replay-prompt-leak-refine-context.md @@ -0,0 +1,45 @@ +DEVANA-FINDING: v1 +Priority: P1 | Confidence: high | Security-sensitive: yes | Status: open +Location: src/replay.rs:349-362 | Slug: replay-prompt-leak-refine-context + +# Full-debug replay persists refine prompts despite documented redaction + +## Finding + +README states that full-debug replay snapshots redact provider secrets and prompt fields. Runtime clears `system_prompt` from sanitized envelopes and redacts prompt keys in config snapshots, but `replay_refine_context` writes the full materialized `transcript.system_prompt` into `record.json` under `refine_context.system_prompt`. A unit test explicitly expects this behavior. + +## Violated Invariant Or Contract + +Full-debug replay artifacts should not persist prompt fields when documentation promises prompt redaction. + +## Oracle + +README line 539: "full-debug replay snapshots redact provider secrets and prompt fields"; `replay_refine_context` at `replay.rs:349-362` copies `config.transcript.system_prompt` verbatim; test `persist_replay_includes_system_prompt_only_in_refine_context_when_refine_is_present` at `replay.rs:1060-1118` asserts the prompt is present in `refine_context`. + +## Counterexample + +1. User sets `replay_detail = "full_debug"` with custom `system_prompt` or `system_prompt_append` containing vocabulary JSON, names, or project hints. +2. Pipeline includes a `refine` step. +3. `persist_replay` writes `record.json` with `refine_context.system_prompt` containing the full hint text. +4. Envelope and config snapshots have prompts cleared/redacted, but the refine context field still leaks the material. + +## Why It Might Matter + +Replay directories may be shared, backed up, or inspected on less-trusted machines. Prompts can contain private vocabulary, contact names, internal project terms, and stylistic instructions the user believed were redacted. + +## Proof + +Contract mismatch: README redaction promise vs `replay_refine_context` writer and its regression test. + +Dataflow trace: live `AppConfig.transcript.system_prompt` → `replay_refine_context` → `record.json` while `sanitized_envelope_for_replay` clears envelope prompts. + +## Counterevidence Checked + +`is_prompt_config_key` redacts `system_prompt` / `system_prompt_append` in config JSON snapshots. `sanitize_envelope_in_place` clears envelope `transcript.system_prompt`. No redaction applied to `ReplayRefineContext.system_prompt`. + +## Suggested Next Step + +Omit `refine_context.system_prompt` in full-debug mode (or apply the same redaction policy as envelope/config snapshots) and update the regression test accordingly. + +DEVANA-KEY: src/replay.rs:349-362 | P1 | replay-prompt-leak-refine-context +DEVANA-SUMMARY: P1 high src/replay.rs:349-362 - Full-debug replay writes refine prompts into record.json despite README claiming prompt fields are redacted. \ No newline at end of file diff --git a/.devana/20260625T083537Z-P2-whitespace-only-text-injected.md b/.devana/20260625T083537Z-P2-whitespace-only-text-injected.md new file mode 100644 index 0000000..346aa28 --- /dev/null +++ b/.devana/20260625T083537Z-P2-whitespace-only-text-injected.md @@ -0,0 +1,44 @@ +DEVANA-FINDING: v1 +Priority: P2 | Confidence: high | Security-sensitive: no | Status: open +Location: src/orchestrator.rs:84-86 | Slug: whitespace-only-text-injected + +# Whitespace-only pipeline text is treated as injectable + +## Finding + +Injection routing treats any non-zero-length string as injectable text. Whitespace-only `output.final_text` or `transcript.raw_text` wins over a usable counterpart field, and `inject_checked` accepts whitespace because it only rejects `text.is_empty()`, not trimmed emptiness. Downstream STT and refine paths use trim-based emptiness checks, so behavior is inconsistent. + +## Violated Invariant Or Contract + +Injectable text should be semantically non-empty. When `output.final_text` is whitespace-only, injection should fall back to `transcript.raw_text` or inject nothing. + +## Oracle + +`non_empty_text` in `orchestrator.rs:84-86` uses `!value.is_empty()` without trim; `inject_checked` in `lib.rs:411-415` mirrors that; STT tools use trim checks (e.g. `stt_whisper_cpp_tool.rs` `has_non_empty_raw_text`); `scoring.rs:224-226` shares the same non-trimming helper. + +## Counterexample + +Pipeline completes with `output.final_text = Some(" \n\t")` and `transcript.raw_text = Some("ship to San Francisco")`. `Orchestrator::route_injection` selects `OutputFinalText(" \n\t")`. `inject_checked` succeeds and types whitespace into the focused app instead of the transcript. + +A text-filter pipeline step can also write untrimmed stdout to `transcript.raw_text` via `runner/codec.rs`, producing the same routing outcome. + +## Why It Might Matter + +User-visible wrong injection (blank-looking output) or loss of a usable transcript when a downstream step leaves whitespace in `final_text`. + +## Proof + +Control-flow trace: `route_envelope` prefers `output.final_text` → `non_empty_text` accepts whitespace → `inject_checked` proceeds. + +Counterexample value: `" \n\t"` in `output.final_text` with non-empty `transcript.raw_text`. + +## Counterevidence Checked + +Refine acceptance trims and rejects empty candidate output (`refine.rs:411-420`). Default STT steps write trimmed transcripts, so this typically requires a downstream text-filter or manual envelope mutation. + +## Suggested Next Step + +Align `non_empty_text` and `inject_checked` with trim-based emptiness used by STT/refine, or treat whitespace-only `final_text` as absent for routing. + +DEVANA-KEY: src/orchestrator.rs:84-86 | P2 | whitespace-only-text-injected +DEVANA-SUMMARY: P2 high src/orchestrator.rs:84-86 - Whitespace-only final_text is preferred over a real transcript and passes inject_checked, causing blank injection. \ No newline at end of file diff --git a/.devana/20260625T083538Z-P2-external-toggle-start-gate.md b/.devana/20260625T083538Z-P2-external-toggle-start-gate.md new file mode 100644 index 0000000..9cadb04 --- /dev/null +++ b/.devana/20260625T083538Z-P2-external-toggle-start-gate.md @@ -0,0 +1,46 @@ +DEVANA-FINDING: v1 +Priority: P2 | Confidence: high | Security-sensitive: no | Status: open +Location: src/external_control/action.rs:45-53 | Slug: external-toggle-start-gate + +# External toggle incorrectly gated by start_recording_enabled when idle + +## Finding + +README documents that only `start` requires `start_recording_enabled = true`, while `toggle` should start recording when idle regardless of that flag. Runtime applies the same idle gate to `Toggle` as to `Start`, so external `muninn://toggle` and MCP-equivalent paths cannot start capture when the flag is false, even though tray toggle still works via `to_app_event(..., true)`. + +## Violated Invariant Or Contract + +External `toggle` when idle should begin recording without requiring `start_recording_enabled`, matching README action semantics. + +## Oracle + +README lines 285-287: `start` is gated; `toggle` "starts when idle, otherwise stops the active recording" with no `start_recording_enabled` mention. `ExternalControlAction::Toggle` at `action.rs:45-53` returns `Disabled` when idle and `!start_recording_enabled`. `runtime_worker.rs:218-232` drops disabled external starts with a log. + +## Counterexample + +1. Default config: `start_recording_enabled = false`. +2. Agent opens `muninn://toggle` while Muninn is idle. +3. `resolve` returns `ExternalControlOutcome::Disabled`. +4. Worker logs "external recording start blocked" and continues; no recording starts. +5. Tray click toggle still starts recording because `to_app_event` hardcodes `start_recording_enabled = true`. + +## Why It Might Matter + +Automation agents documented to use `toggle` cannot start capture unless operators also enable the stricter `start_recording_enabled` opt-in meant for `start` only. + +## Proof + +Contract mismatch between README external-control semantics and `ExternalControlAction::Toggle` idle branch. + +Cross-entry mismatch: tray `Toggle` vs URL/MCP `Toggle` under the same config. + +## Counterevidence Checked + +`Start` correctly gated. Stop/cancel/toggle-while-recording paths do not use the start gate. This is not the documented MCP `start_recording_enabled` launch snapshot issue. + +## Suggested Next Step + +Split toggle idle handling from the `start_recording_enabled` check, or update README if the gate is intentional. + +DEVANA-KEY: src/external_control/action.rs:45-53 | P2 | external-toggle-start-gate +DEVANA-SUMMARY: P2 high src/external_control/action.rs:45-53 - External toggle when idle is blocked by start_recording_enabled despite README saying only start requires that opt-in. \ No newline at end of file diff --git a/.devana/20260625T083539Z-P2-url-scheme-disable-ignored-reload.md b/.devana/20260625T083539Z-P2-url-scheme-disable-ignored-reload.md new file mode 100644 index 0000000..7509f97 --- /dev/null +++ b/.devana/20260625T083539Z-P2-url-scheme-disable-ignored-reload.md @@ -0,0 +1,43 @@ +DEVANA-FINDING: v1 +Priority: P2 | Confidence: high | Security-sensitive: yes | Status: open +Location: src/runtime_shell.rs:72-74, src/external_control/url_scheme.rs:51-72 | Slug: url-scheme-disable-ignored-reload + +# url_scheme_enabled=false after live reload leaves handler active + +## Finding + +The `muninn://` Apple Event handler is installed only at bootstrap when `url_scheme_enabled` is true. Live config reload updates in-memory config but never unregisters the handler or checks the flag when URLs arrive. Disabling `url_scheme_enabled` at runtime therefore does not stop external URL control. + +## Violated Invariant Or Contract + +When `external_control.url_scheme_enabled = false`, `muninn://` verbs should not reach the runtime worker. + +## Oracle + +`install_url_scheme_handler` gated at `runtime_shell.rs:72-74`; `handle_get_url_event` forwards every parsed verb without reading config (`url_scheme.rs:67-71`); config reload path updates worker config (`runtime_worker.rs` `ReloadConfig`) but has no URL handler lifecycle. README presents `url_scheme_enabled` as the control surface (line 276) without stating it is launch-only (unlike `mcp_enabled`). + +## Counterexample + +1. Launch Muninn with `url_scheme_enabled = true`. +2. Operator edits config to `url_scheme_enabled = false`; watcher emits `ConfigReloaded`. +3. Worker applies new config; handler remains registered. +4. `open "muninn://stop"` while a recording is active still dispatches `ExternalControl(Stop)` and can finish capture and run the pipeline. + +## Why It Might Matter + +Operators who disable URL control via config reload believe external agents can no longer drive Muninn, but stop/toggle/cancel remain reachable from any local app that can open `muninn://` links. + +## Proof + +Control-flow trace: bootstrap install (once) → reload mutates config only → `handle_get_url_event` unconditional forward. + +## Counterevidence Checked + +`start_recording_enabled` is enforced on the worker reload path for start/toggle-when-idle, but stop/cancel are not gated by that flag. No `url_scheme_enabled` read exists outside initial install. + +## Suggested Next Step + +Consult `url_scheme_enabled` on each URL event, or unregister/re-register the handler when the flag changes on reload. + +DEVANA-KEY: src/runtime_shell.rs:72-74,src/external_control/url_scheme.rs:51-72 | P2 | url-scheme-disable-ignored-reload +DEVANA-SUMMARY: P2 high src/runtime_shell.rs:72-74 - Disabling url_scheme_enabled via live reload does not stop the installed muninn:// handler from dispatching control actions. \ No newline at end of file diff --git a/.devana/20260625T083540Z-P2-openai-streaming-truncates-segments.md b/.devana/20260625T083540Z-P2-openai-streaming-truncates-segments.md new file mode 100644 index 0000000..38c6c42 --- /dev/null +++ b/.devana/20260625T083540Z-P2-openai-streaming-truncates-segments.md @@ -0,0 +1,46 @@ +DEVANA-FINDING: v1 +Priority: P2 | Confidence: medium | Security-sensitive: no | Status: open +Location: src/streaming_transcription/openai.rs:278-285 | Slug: openai-streaming-truncates-segments + +# OpenAI streaming finish exits after first completed segment + +## Finding + +`OpenAiStreamingSession::finish` breaks out of the websocket read loop as soon as any new `completed` transcription event arrives, without draining later completions. The transcript accumulator is built and tested to join multiple completed segments in arrival order, but `finish()` never consumes the remainder of the stream before calling `into_outcome()`. + +## Violated Invariant Or Contract + +Streaming finish should accumulate all final completed transcription segments before seeding `transcript.raw_text` for the recorded pipeline. + +## Oracle + +`finish()` break at `openai.rs:283-285` after first `completed_count` increase; `OpenAiTranscriptAccumulator::into_outcome` joins all entries in `self.completed` (`openai.rs:470-488`); test `parser_accumulates_completed_events_in_arrival_order` at `openai.rs:707-726` expects `"second first"` from two completions that `finish()` would not both receive if they arrive after the first post-commit event. + +## Counterexample + +1. Streaming mode with OpenAI Realtime; user finishes utterance; `input_audio_buffer.commit` is sent. +2. Server emits completed transcript `"first segment"` → loop breaks immediately. +3. Server later emits completed transcript `"second segment"` on the same socket → never read. +4. `into_outcome()` returns only `"first segment"`. +5. Recorded STT fallback is skipped because `has_non_empty_raw_text` is true with truncated text. + +## Why It Might Matter + +Seeded `transcript.raw_text` can be materially shorter than the spoken utterance, and downstream refine/injection operate on the truncated seed. + +## Proof + +Control-flow trace: commit → read loop → break on first completion increment → close socket → `into_outcome()` with partial `completed` vec. + +Counterevidence checked: deltas populate `partial_text` but `into_outcome()` ignores them. + +## Counterevidence Checked + +If the provider emits a single consolidated completion per utterance, behavior is correct. Multiple completions are explicitly supported by accumulator tests. Empty/whitespace first completion could yield no seed while a later completion holds the real text. + +## Suggested Next Step + +Drain the websocket until close or an explicit end signal after commit, then build the outcome from all completed events. + +DEVANA-KEY: src/streaming_transcription/openai.rs:278-285 | P2 | openai-streaming-truncates-segments +DEVANA-SUMMARY: P2 medium src/streaming_transcription/openai.rs:278-285 - OpenAI streaming finish stops reading after the first completed segment, truncating multi-part transcripts. \ No newline at end of file From 1376c6352b23c1c00325acab0728717f0bb9c2e8 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Fri, 26 Jun 2026 20:55:06 +0100 Subject: [PATCH 13/16] Fix OpenAI streaming transcript finalization --- ...5T083533Z-P0-apple-speech-helper-toctou.md | 17 +- ...P1-accessibility-grant-loses-transcript.md | 14 +- ...-P1-replay-audio-deleted-before-persist.md | 16 +- ...6Z-P1-replay-prompt-leak-refine-context.md | 13 +- ...83537Z-P2-whitespace-only-text-injected.md | 14 +- ...5T083538Z-P2-external-toggle-start-gate.md | 15 +- ...9Z-P2-url-scheme-disable-ignored-reload.md | 16 +- ...-P2-openai-streaming-truncates-segments.md | 34 +- ...1240Z-P3-streaming-error-close-as-empty.md | 5 +- src/streaming_transcription/openai.rs | 307 +++++++++++++++--- 10 files changed, 393 insertions(+), 58 deletions(-) diff --git a/.devana/20260625T083533Z-P0-apple-speech-helper-toctou.md b/.devana/20260625T083533Z-P0-apple-speech-helper-toctou.md index bdef103..4f89417 100644 --- a/.devana/20260625T083533Z-P0-apple-speech-helper-toctou.md +++ b/.devana/20260625T083533Z-P0-apple-speech-helper-toctou.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -Priority: P0 | Confidence: high | Security-sensitive: yes | Status: open +Priority: P0 | Confidence: high | Security-sensitive: yes | Status: fixed Location: src/stt_apple_speech_tool.rs:615-653 | Slug: apple-speech-helper-toctou # Apple Speech helper skips integrity check after first materialization @@ -42,5 +42,18 @@ Counterexample value: replaced bytes at predictable `helper_output_path()` after Re-run `helper_needs_refresh()` (or equivalent signature check) before every spawn, or execute from a non-user-writable location with immutable permissions after install. +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-26: fixed. The cached helper path fast path is gone. Both sync and + async helper execution call `materialize_helper_binary()` immediately before + spawning the helper, and `materialize_helper_binary()` now reads the on-disk + helper on every call via `helper_needs_refresh(&path)`, rewrites mismatched + bytes, ensures executable permissions, and only then returns the path. The + original "replace the cached helper after first materialization and wait for a + later dictation" counterexample is blocked. Residual checked: there is still a + narrower check-to-spawn race after per-call validation, but that is not the + cached-path bug reported here. + DEVANA-KEY: src/stt_apple_speech_tool.rs:615-653 | P0 | apple-speech-helper-toctou -DEVANA-SUMMARY: P0 high src/stt_apple_speech_tool.rs:615-653 - Cached Apple Speech helper path skips re-verification, allowing same-user replacement and arbitrary code execution on later transcriptions. \ No newline at end of file +DEVANA-SUMMARY: Status=fixed | P0 high src/stt_apple_speech_tool.rs:615-653 - Cached Apple Speech helper path skips re-verification, allowing same-user replacement and arbitrary code execution on later transcriptions. diff --git a/.devana/20260625T083534Z-P1-accessibility-grant-loses-transcript.md b/.devana/20260625T083534Z-P1-accessibility-grant-loses-transcript.md index 65d465b..7e5a378 100644 --- a/.devana/20260625T083534Z-P1-accessibility-grant-loses-transcript.md +++ b/.devana/20260625T083534Z-P1-accessibility-grant-loses-transcript.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -Priority: P1 | Confidence: high | Security-sensitive: no | Status: open +Priority: P1 | Confidence: high | Security-sensitive: no | Status: fixed Location: src/runtime_pipeline.rs:87-91,143-144 | Slug: accessibility-grant-loses-transcript # Accessibility grant during first injection silently discards completed transcript @@ -42,5 +42,15 @@ Cross-entry mismatch: recording-start abort happens before pipeline work; inject After a successful Accessibility grant in the same interaction, proceed with `inject_checked` instead of aborting, or retain the injection text and expose an explicit retry that does not require re-recording. +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-26: fixed. `should_abort_injection` now returns `false` when the + refreshed preflight allows injection, even if Accessibility was requested in + the same interaction. `process_and_inject` therefore proceeds to + `inject_checked` after a successful grant instead of returning before + injection. If Accessibility remains denied, the abort path is still used. The + original grant-then-discard counterexample is blocked. + DEVANA-KEY: src/runtime_pipeline.rs:87-91,143-144 | P1 | accessibility-grant-loses-transcript -DEVANA-SUMMARY: P1 high src/runtime_pipeline.rs:87-91,143-144 - Granting Accessibility on first injection aborts silently and deletes the WAV, losing a completed transcript despite the retry log message. \ No newline at end of file +DEVANA-SUMMARY: Status=fixed | P1 high src/runtime_pipeline.rs:87-91,143-144 - Granting Accessibility on first injection aborts silently and deletes the WAV, losing a completed transcript despite the retry log message. diff --git a/.devana/20260625T083535Z-P1-replay-audio-deleted-before-persist.md b/.devana/20260625T083535Z-P1-replay-audio-deleted-before-persist.md index 310d504..f43398b 100644 --- a/.devana/20260625T083535Z-P1-replay-audio-deleted-before-persist.md +++ b/.devana/20260625T083535Z-P1-replay-audio-deleted-before-persist.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -Priority: P1 | Confidence: high | Security-sensitive: no | Status: open +Priority: P1 | Confidence: high | Security-sensitive: no | Status: fixed Location: src/runtime_pipeline.rs:76,143-144 | Slug: replay-audio-deleted-before-persist # Replay audio retention races temp WAV deletion @@ -41,5 +41,17 @@ Hard-link/copy logic in `retain_audio_file` works when the source still exists. Retain or copy audio before deleting the temp WAV (or block cleanup until persist acknowledges audio retention). +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-26: fixed. `ReplayPersistenceService::enqueue` now returns `true` + only after the worker queue accepts the replay request, which transfers temp + WAV ownership to the replay worker. `process_and_inject` stores that result in + `worker_owns_wav` and skips main-thread cleanup when the worker owns the file; + the worker deletes the WAV only after `persist_request` returns, so audio + retention runs before cleanup. If replay is disabled or enqueue fails, the + caller still owns and deletes the WAV. The original accepted-enqueue versus + immediate-cleanup race is blocked. + DEVANA-KEY: src/runtime_pipeline.rs:76,143-144 | P1 | replay-audio-deleted-before-persist -DEVANA-SUMMARY: P1 high src/runtime_pipeline.rs:76,143-144 - Async replay enqueue races synchronous WAV cleanup, so full-debug audio retention is often silently dropped. \ No newline at end of file +DEVANA-SUMMARY: Status=fixed | P1 high src/runtime_pipeline.rs:76,143-144 - Async replay enqueue races synchronous WAV cleanup, so full-debug audio retention is often silently dropped. diff --git a/.devana/20260625T083536Z-P1-replay-prompt-leak-refine-context.md b/.devana/20260625T083536Z-P1-replay-prompt-leak-refine-context.md index 6e48682..9aacafa 100644 --- a/.devana/20260625T083536Z-P1-replay-prompt-leak-refine-context.md +++ b/.devana/20260625T083536Z-P1-replay-prompt-leak-refine-context.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -Priority: P1 | Confidence: high | Security-sensitive: yes | Status: open +Priority: P1 | Confidence: high | Security-sensitive: yes | Status: fixed Location: src/replay.rs:349-362 | Slug: replay-prompt-leak-refine-context # Full-debug replay persists refine prompts despite documented redaction @@ -41,5 +41,14 @@ Dataflow trace: live `AppConfig.transcript.system_prompt` → `replay_refine_con Omit `refine_context.system_prompt` in full-debug mode (or apply the same redaction policy as envelope/config snapshots) and update the regression test accordingly. +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-26: fixed. `replay_refine_context` now preserves the + `refine_context.system_prompt` marker only as `[redacted]` instead of copying + the configured prompt text. Config snapshots still remove prompt keys, and + envelope/pipeline-outcome replay sanitization still clears transcript system + prompts. The original full-debug `record.json` prompt leak is blocked. + DEVANA-KEY: src/replay.rs:349-362 | P1 | replay-prompt-leak-refine-context -DEVANA-SUMMARY: P1 high src/replay.rs:349-362 - Full-debug replay writes refine prompts into record.json despite README claiming prompt fields are redacted. \ No newline at end of file +DEVANA-SUMMARY: Status=fixed | P1 high src/replay.rs:349-362 - Full-debug replay writes refine prompts into record.json despite README claiming prompt fields are redacted. diff --git a/.devana/20260625T083537Z-P2-whitespace-only-text-injected.md b/.devana/20260625T083537Z-P2-whitespace-only-text-injected.md index 346aa28..c87a1af 100644 --- a/.devana/20260625T083537Z-P2-whitespace-only-text-injected.md +++ b/.devana/20260625T083537Z-P2-whitespace-only-text-injected.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -Priority: P2 | Confidence: high | Security-sensitive: no | Status: open +Priority: P2 | Confidence: high | Security-sensitive: no | Status: fixed Location: src/orchestrator.rs:84-86 | Slug: whitespace-only-text-injected # Whitespace-only pipeline text is treated as injectable @@ -40,5 +40,15 @@ Refine acceptance trims and rejects empty candidate output (`refine.rs:411-420`) Align `non_empty_text` and `inject_checked` with trim-based emptiness used by STT/refine, or treat whitespace-only `final_text` as absent for routing. +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-26: fixed. `orchestrator::non_empty_text` now treats + whitespace-only strings as absent by checking `!value.trim().is_empty()`, so a + blank `output.final_text` falls back to usable `transcript.raw_text` or no + injection. `TextInjector::inject_checked` also rejects trim-empty text before + calling the platform injector. The original blank-looking injection + counterexample is blocked. + DEVANA-KEY: src/orchestrator.rs:84-86 | P2 | whitespace-only-text-injected -DEVANA-SUMMARY: P2 high src/orchestrator.rs:84-86 - Whitespace-only final_text is preferred over a real transcript and passes inject_checked, causing blank injection. \ No newline at end of file +DEVANA-SUMMARY: Status=fixed | P2 high src/orchestrator.rs:84-86 - Whitespace-only final_text is preferred over a real transcript and passes inject_checked, causing blank injection. diff --git a/.devana/20260625T083538Z-P2-external-toggle-start-gate.md b/.devana/20260625T083538Z-P2-external-toggle-start-gate.md index 9cadb04..c94566e 100644 --- a/.devana/20260625T083538Z-P2-external-toggle-start-gate.md +++ b/.devana/20260625T083538Z-P2-external-toggle-start-gate.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -Priority: P2 | Confidence: high | Security-sensitive: no | Status: open +Priority: P2 | Confidence: high | Security-sensitive: no | Status: stale Location: src/external_control/action.rs:45-53 | Slug: external-toggle-start-gate # External toggle incorrectly gated by start_recording_enabled when idle @@ -42,5 +42,16 @@ Cross-entry mismatch: tray `Toggle` vs URL/MCP `Toggle` under the same config. Split toggle idle handling from the `start_recording_enabled` check, or update README if the gate is intentional. +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-26: stale. The behavior was not changed to allow idle external + `toggle` without the microphone-start opt-in; instead the current contract now + explicitly says idle `toggle` is subject to the same + `start_recording_enabled = true` gate as `start`. `ExternalControlAction` and + its tests encode that gate. The original report's README/code mismatch no + longer exists, so this is not a current runtime defect under the documented + semantics. + DEVANA-KEY: src/external_control/action.rs:45-53 | P2 | external-toggle-start-gate -DEVANA-SUMMARY: P2 high src/external_control/action.rs:45-53 - External toggle when idle is blocked by start_recording_enabled despite README saying only start requires that opt-in. \ No newline at end of file +DEVANA-SUMMARY: Status=stale | P2 high src/external_control/action.rs:45-53 - External idle toggle is now documented and tested as gated by start_recording_enabled, so the original README/code mismatch no longer applies. diff --git a/.devana/20260625T083539Z-P2-url-scheme-disable-ignored-reload.md b/.devana/20260625T083539Z-P2-url-scheme-disable-ignored-reload.md index 7509f97..fd449d2 100644 --- a/.devana/20260625T083539Z-P2-url-scheme-disable-ignored-reload.md +++ b/.devana/20260625T083539Z-P2-url-scheme-disable-ignored-reload.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -Priority: P2 | Confidence: high | Security-sensitive: yes | Status: open +Priority: P2 | Confidence: high | Security-sensitive: yes | Status: fixed Location: src/runtime_shell.rs:72-74, src/external_control/url_scheme.rs:51-72 | Slug: url-scheme-disable-ignored-reload # url_scheme_enabled=false after live reload leaves handler active @@ -39,5 +39,17 @@ Control-flow trace: bootstrap install (once) → reload mutates config only → Consult `url_scheme_enabled` on each URL event, or unregister/re-register the handler when the flag changes on reload. +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-26: fixed. The URL handler now uses an atomic + `URL_SCHEME_ENABLED` gate checked inside `handle_get_url_event` before + dispatching any parsed action. `AppRuntime::run` seeds that gate from launch + config before installing the handler and refreshes it on every + `ConfigReloaded` event. Disabling `external_control.url_scheme_enabled` via + live reload therefore causes subsequent `muninn://` events to be ignored + before they reach the runtime worker. The original reload-disable + counterexample is blocked. + DEVANA-KEY: src/runtime_shell.rs:72-74,src/external_control/url_scheme.rs:51-72 | P2 | url-scheme-disable-ignored-reload -DEVANA-SUMMARY: P2 high src/runtime_shell.rs:72-74 - Disabling url_scheme_enabled via live reload does not stop the installed muninn:// handler from dispatching control actions. \ No newline at end of file +DEVANA-SUMMARY: Status=fixed | P2 high src/runtime_shell.rs:72-74 - Disabling url_scheme_enabled via live reload does not stop the installed muninn:// handler from dispatching control actions. diff --git a/.devana/20260625T083540Z-P2-openai-streaming-truncates-segments.md b/.devana/20260625T083540Z-P2-openai-streaming-truncates-segments.md index 38c6c42..b0b2d80 100644 --- a/.devana/20260625T083540Z-P2-openai-streaming-truncates-segments.md +++ b/.devana/20260625T083540Z-P2-openai-streaming-truncates-segments.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -Priority: P2 | Confidence: medium | Security-sensitive: no | Status: open +Priority: P2 | Confidence: medium | Security-sensitive: no | Status: fixed Location: src/streaming_transcription/openai.rs:278-285 | Slug: openai-streaming-truncates-segments # OpenAI streaming finish exits after first completed segment @@ -42,5 +42,35 @@ If the provider emits a single consolidated completion per utterance, behavior i Drain the websocket until close or an explicit end signal after commit, then build the outcome from all completed events. +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-26: still open, narrowed. `finish()` no longer breaks immediately + after the first completed event: it waits for the first completion, then drains + additional messages for `POST_COMPLETION_DRAIN_GRACE` (300 ms). That blocks + the back-to-back multi-completion counterexample covered by current regression + tests. A delayed second final completion after the grace still follows + `Err(_elapsed) => break`, closes the socket, and builds the outcome from only + the completions already read. No repo-local guard, provider contract, or test + proves every final segment arrives within that 300 ms grace, so the report + remains open with the delayed-segment counterexample. +- 2026-06-26: fixed. `finish()` no longer uses a fixed post-completion grace. + It waits for the server's `input_audio_buffer.committed` acknowledgement, + records that committed item id, then uses the committed item's + `conversation.item.done` content list to learn which audio content indexes + must produce `conversation.item.input_audio_transcription.completed` events. + The loop only exits once all finalized audio content indexes for that + committed item have completed (or the socket closes/errors, with the existing + controller finish timeout as the outer bound). This blocks both the delayed + final event counterexample and the review-discovered same-item delayed + multi-content counterexample. Final outcome assembly now prefers the + committed item's transcript parts and orders them by `content_index` so + unrelated item completions cannot contaminate the committed utterance. Added + `finish_waits_for_delayed_completion_for_committed_item` and + `finish_waits_for_all_finalized_audio_content_indexes`, plus coverage for + unrelated item completions and out-of-order committed audio parts; focused + OpenAI streaming tests, broader streaming tests, and the full library suite + pass. + DEVANA-KEY: src/streaming_transcription/openai.rs:278-285 | P2 | openai-streaming-truncates-segments -DEVANA-SUMMARY: P2 medium src/streaming_transcription/openai.rs:278-285 - OpenAI streaming finish stops reading after the first completed segment, truncating multi-part transcripts. \ No newline at end of file +DEVANA-SUMMARY: Status=fixed | P2 medium src/streaming_transcription/openai.rs:278-285 - OpenAI streaming finish now waits for transcription completion of the committed item instead of closing after a fixed post-completion grace. diff --git a/.devana/20260625T091240Z-P3-streaming-error-close-as-empty.md b/.devana/20260625T091240Z-P3-streaming-error-close-as-empty.md index e64b7a1..cecc4ff 100644 --- a/.devana/20260625T091240Z-P3-streaming-error-close-as-empty.md +++ b/.devana/20260625T091240Z-P3-streaming-error-close-as-empty.md @@ -74,9 +74,10 @@ After working this report, preserve the original finding body. Update line 2 `St - 2026-06-25: open by Devana. Initial report written from static source inspection. - 2026-06-26: fixed. Both backends now inspect the close frame in the `Message::Close` arm. A frame with a non-benign code (anything other than 1000 - Normal / 1001 Going Away, or no frame at all) is captured as a + Normal / 1001 Going Away) is captured as a `StreamingTranscriptionError::failed(...)` carrying the close code and reason - (codes `deepgram_closed_with_error` / `openai_realtime_closed_with_error`). + (codes `deepgram_closed_with_error` / `openai_realtime_closed_with_error`); + a close without a frame is treated as benign. After the loop, the error is returned only when no usable final text was produced, so a transcript completed before a trailing error close is still returned as success. Added regression tests: deepgram diff --git a/src/streaming_transcription/openai.rs b/src/streaming_transcription/openai.rs index d2d2e6c..5eca2f0 100644 --- a/src/streaming_transcription/openai.rs +++ b/src/streaming_transcription/openai.rs @@ -1,10 +1,9 @@ -use std::time::Duration; - 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, @@ -28,10 +27,6 @@ const RECORDING_CONFIG_UNSUPPORTED_CODE: &str = "openai_realtime_recording_confi 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"; -/// After the first completed transcription segment arrives post-commit, wait at -/// most this long for additional segments/content parts before finalizing, so a -/// single-segment utterance is not delayed by the full streaming finish timeout. -const POST_COMPLETION_DRAIN_GRACE: Duration = Duration::from_millis(300); #[derive(Debug, Clone, Copy, Default)] pub struct OpenAiStreamingTranscriptionProvider; @@ -282,38 +277,26 @@ where )) .await?; - // Drain completed transcription segments after the commit. The realtime - // session is persistent and does not close the socket once an utterance is - // transcribed, so we cannot read until close (the upstream finish timeout - // would abort and discard the transcript). Instead, wait for the first - // completed segment, then briefly drain any further segments/content parts - // that arrive back-to-back before finalizing. Stopping on the first - // completion would truncate multi-segment transcripts. + // The realtime session is persistent and does not close after a single + // utterance. The server acknowledges this commit with the user item id + // (`input_audio_buffer.committed`) and later finalizes the item with its + // audio content indexes (`conversation.item.done`). Wait until each audio + // content part of that committed item has a completed transcript instead of + // using a fixed post-completion grace, which can truncate delayed final + // events. The upstream finish timeout remains the outer bound for a missing + // acknowledgement/finalization/completion. let mut error_close: Option = None; loop { - let message = if self.transcript.completed_count() == 0 { - // No completion yet: wait for the next message. Transcription - // latency is bounded by the upstream streaming finish timeout. - match self.socket.next().await? { - Some(message) => message, - None => break, - } - } else { - // At least one completion captured: only wait a short grace for - // additional segments so a single-segment utterance is not delayed - // by the full finish timeout. - match tokio::time::timeout(POST_COMPLETION_DRAIN_GRACE, self.socket.next()).await { - Ok(result) => match result? { - Some(message) => message, - None => break, - }, - Err(_elapsed) => break, - } + let Some(message) = self.socket.next().await? else { + break; }; match message { Message::Text(text) => { self.transcript.handle_text_message(&text)?; + if self.transcript.committed_item_transcription_finished() { + break; + } } Message::Binary(_) | Message::Pong(_) | Message::Frame(_) => {} Message::Ping(payload) => { @@ -430,6 +413,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, } @@ -455,6 +440,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( @@ -519,13 +506,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(); @@ -545,6 +604,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 @@ -915,11 +981,22 @@ api_key = "config-key" Arc::clone(&state), [ Message::Text( - r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"item_001","content_index":0,"transcript":"first segment"}"# + 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":"second segment"}"# + 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(), ), ], @@ -931,7 +1008,110 @@ api_key = "config-key" .expect("session should finalize"); // Regression: finish() must not stop after the first completed segment. - assert_eq!(outcome.raw_text.as_deref(), Some("first segment second segment")); + 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"); + + // Regression: a fixed post-completion grace can close the persistent socket + // before a delayed final event arrives. + 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")] @@ -940,6 +1120,13 @@ api_key = "config-key" 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(), @@ -1078,13 +1265,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, @@ -1093,6 +1298,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> { @@ -1111,7 +1332,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)) } } From 6ceafecbb5822f538250f4cd2f8454115b9867c4 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Fri, 26 Jun 2026 21:38:28 +0100 Subject: [PATCH 14/16] comments --- src/audio.rs | 29 ++++++ src/audio/render.rs | 21 ++++ src/autostart.rs | 16 +++ src/config.rs | 125 ++++++++++++++++++++++++ src/config/logging.rs | 8 +- src/config_watch.rs | 23 ++++- src/envelope.rs | 26 ++++- src/error.rs | 24 ++++- src/external_control/action.rs | 28 +++--- src/external_control/mcp.rs | 4 + src/external_control/status.rs | 24 +++++ src/external_control/url_scheme.rs | 12 +-- src/hotkeys.rs | 22 +++++ src/injector.rs | 12 +++ src/internal_tools.rs | 32 ++++++ src/lib.rs | 108 +++++++++++++++++++- src/logging.rs | 27 +++++ src/main.rs | 9 +- src/mock.rs | 53 ++++++++++ src/orchestrator.rs | 26 ++++- src/permissions.rs | 24 +++++ src/platform.rs | 24 ++++- src/refine.rs | 15 +++ src/replay.rs | 28 ++++-- src/replay_dispatch.rs | 22 +++-- src/runner.rs | 68 +++++++++++++ src/runner/codec.rs | 21 ++++ src/runner/execution.rs | 12 +++ src/runner/transport.rs | 44 +++++---- src/runtime_flow.rs | 35 +++++++ src/runtime_permissions.rs | 38 ++++++- src/runtime_pipeline.rs | 31 +++++- src/runtime_shell.rs | 26 +++-- src/runtime_tray.rs | 33 +++++++ src/runtime_worker.rs | 15 +++ src/scoring.rs | 34 ++++++- src/secrets.rs | 15 ++- src/state.rs | 26 ++++- src/streaming_transcription.rs | 43 ++++++++ src/streaming_transcription/deepgram.rs | 14 +-- src/streaming_transcription/google.rs | 9 ++ src/streaming_transcription/openai.rs | 25 ++--- src/stt_apple_speech_tool.rs | 19 +++- src/stt_deepgram_tool.rs | 14 +++ src/stt_google_tool.rs | 23 +++-- src/stt_openai_tool.rs | 14 +++ src/stt_whisper_cpp_tool.rs | 15 +++ src/target_context.rs | 19 +++- src/transcription.rs | 39 ++++++++ 49 files changed, 1244 insertions(+), 130 deletions(-) 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..dbfcba9 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 { @@ -32,4 +38,4 @@ impl Default for LoggingConfig { replay_max_bytes: 52_428_800, } } -} +} \ No newline at end of file diff --git a/src/config_watch.rs b/src/config_watch.rs index 700d113..b847c94 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 { @@ -219,4 +240,4 @@ mod tests { min ); } -} +} \ No newline at end of file diff --git a/src/envelope.rs b/src/envelope.rs index 32cc613..5016c93 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")] @@ -182,4 +206,4 @@ mod tests { assert!(decoded.output.final_text.is_none()); assert!(decoded.errors.is_empty()); } -} +} \ No newline at end of file diff --git a/src/error.rs b/src/error.rs index 647e64f..3430393 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,28 @@ impl fmt::Display for PermissionKind { } } +/// 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, + /// One or more required TCC permissions are not granted. #[error("missing required permissions: {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 +71,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 { @@ -57,4 +79,4 @@ impl MacosAdapterError { message: message.into(), } } -} +} \ No newline at end of file diff --git a/src/external_control/action.rs b/src/external_control/action.rs index 7858dfa..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. @@ -7,29 +13,29 @@ pub(crate) enum ExternalControlAction { Start, /// Finish the active recording and run the pipeline. No-op when idle. Stop, - /// Start when idle (gated by `start_recording_enabled`, like `Start`), - /// otherwise finish the active recording. + /// Start when idle, otherwise finish the active recording. Toggle, /// Discard the active recording without running the pipeline. 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, } impl ExternalControlAction { /// Map an action onto the [`AppEvent`] appropriate for the current state. /// - /// Starting capture from idle is explicitly gated by `start_recording_enabled` - /// for both `Start` and `Toggle`, so an external agent cannot bypass the - /// microphone opt-in by sending `toggle` instead of `start`. (The tray click - /// path resolves with the gate forced on via `to_app_event`, since a local - /// human action is already trusted.) `Noop` means the action is allowed but has - /// no state transition to perform. + /// Start requests are explicitly gated by config. `Noop` means the action + /// is allowed but has no state transition to perform. pub(crate) fn resolve( self, state: AppState, @@ -66,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), @@ -203,9 +212,6 @@ mod tests { ExternalControlAction::Toggle.resolve(AppState::Idle, true), ExternalControlOutcome::Enabled(AppEvent::DoneTogglePressed) ); - // Security gate: an idle external toggle starts microphone capture, so it - // is gated by start_recording_enabled exactly like Start. Without the - // opt-in an external toggle cannot start capture. assert_eq!( ExternalControlAction::Toggle.resolve(AppState::Idle, false), ExternalControlOutcome::Disabled diff --git a/src/external_control/mcp.rs b/src/external_control/mcp.rs index 5daa257..86ebf0f 100644 --- a/src/external_control/mcp.rs +++ b/src/external_control/mcp.rs @@ -1,4 +1,8 @@ //! 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; 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 1e12a0a..54ba4a4 100644 --- a/src/external_control/url_scheme.rs +++ b/src/external_control/url_scheme.rs @@ -28,16 +28,12 @@ const AE_GET_URL: u32 = 0x4755_524C; // 'GURL' static PROXY: OnceLock> = OnceLock::new(); -/// Whether `muninn://` URLs should currently drive the runtime. The Apple Event -/// handler is installed once at launch, but `url_scheme_enabled` can change at -/// runtime via live config reload, so dispatch is gated on this flag rather than -/// on whether the handler is installed. Defaults to disabled until the launch -/// config is applied. static URL_SCHEME_ENABLED: AtomicBool = AtomicBool::new(false); -/// Update whether `muninn://` URLs are honored. Called with the launch config at -/// bootstrap and again on every live config reload so that disabling -/// `url_scheme_enabled` at runtime immediately stops external URL control. +/// 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); } 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 cdb719a..d255e98 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,6 +411,9 @@ pub trait IndicatorAdapter: Send + Sync { let _ = glyph; self.set_state(state).await } + /// Show `state` for at least `min_duration`, then revert to `fallback_state`. + /// + /// Default implementation ignores timing and delegates to [`IndicatorAdapter::set_state`]. async fn set_temporary_state( &mut self, state: IndicatorState, @@ -358,6 +424,10 @@ pub trait IndicatorAdapter: Send + Sync { let _ = fallback_state; self.set_state(state).await } + /// Temporary indicator update with optional primary and fallback glyphs. + /// + /// Default implementation ignores glyphs and delegates to + /// [`IndicatorAdapter::set_temporary_state`]. async fn set_temporary_state_with_glyph( &mut self, state: IndicatorState, @@ -371,28 +441,51 @@ pub trait IndicatorAdapter: Send + Sync { self.set_temporary_state(state, min_duration, fallback_state) .await } + /// 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,18 +493,27 @@ 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<()> { - // Reject whitespace-only text, not just zero-length, so a blank payload is - // never typed into the focused app. This matches the trim-based emptiness - // used by injection routing and the STT/refine pipeline. if text.trim().is_empty() { return Err(MacosAdapterError::EmptyInjectionText); } diff --git a/src/logging.rs b/src/logging.rs index ffea02d..4f513d8 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,43 @@ 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, }, + /// Config file change detected on disk. ConfigChanged { path: String, }, + /// Recording capture began after permissions passed. RecordingStarted { profile_id: String, voice_id: Option, @@ -45,6 +62,7 @@ pub(crate) enum DiagnosticEvent { mono: bool, source: &'static str, }, + /// Runtime worker failed to build or exited with an error. RuntimeWorkerFailed { stage: &'static str, detail: String, @@ -68,6 +86,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 +102,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 +119,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 +158,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 +183,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 b1d5b2e..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; @@ -556,9 +562,6 @@ mod tests { #[test] fn injection_proceeds_after_accessibility_prompt_when_now_granted() { - // A fresh Accessibility grant that the refreshed preflight already reflects - // must not abort: the utterance is fully transcribed and aborting would - // delete the WAV with no retry path, losing the transcript. assert!(!should_abort_injection( PermissionPreflightStatus::all_granted(), true, diff --git a/src/mock.rs b/src/mock.rs index 7dd2e96..dd74379 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 @@ -135,6 +148,7 @@ impl IndicatorAdapter for MockIndicatorAdapter { } } +/// Configurable mock for macOS permission preflight and request flows. #[derive(Debug, Clone)] pub struct MockPermissionsAdapter { inner: Arc>, @@ -176,6 +190,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 +198,7 @@ impl MockPermissionsAdapter { } } + /// Configure the status returned by [`PermissionsAdapter::preflight`]. pub fn set_preflight_status(&self, status: PermissionPreflightStatus) { self.inner .lock() @@ -190,6 +206,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 +214,7 @@ impl MockPermissionsAdapter { .preflight_result = Err(error); } + /// Count preflight invocations. #[must_use] pub fn preflight_calls(&self) -> usize { self.inner @@ -205,6 +223,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 +231,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 +239,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 +247,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 +255,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 +263,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 +271,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 +280,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 +289,7 @@ impl MockPermissionsAdapter { .request_input_monitoring_calls } + /// Count microphone request invocations. #[must_use] pub fn request_microphone_calls(&self) -> usize { self.inner @@ -271,6 +298,7 @@ impl MockPermissionsAdapter { .request_microphone_calls } + /// Count accessibility request invocations. #[must_use] pub fn request_accessibility_calls(&self) -> usize { self.inner @@ -325,6 +353,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 +372,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 +380,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 +388,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 +397,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 +406,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 +414,7 @@ impl MockHotkeyEventSource { } } + /// Number of queued events or errors not yet consumed. #[must_use] pub fn pending_events(&self) -> usize { self.inner @@ -389,6 +424,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 +447,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 +491,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 +499,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 +508,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 +517,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 +526,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 +534,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 +543,7 @@ impl MockAudioRecorder { .active } + /// Count start invocations (plain and audio-sink starts). #[must_use] pub fn start_calls(&self) -> usize { self.inner @@ -508,6 +552,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 +561,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 +571,7 @@ impl MockAudioRecorder { .clone() } + /// Count stop invocations. #[must_use] pub fn stop_calls(&self) -> usize { self.inner @@ -533,6 +580,7 @@ impl MockAudioRecorder { .stop_calls } + /// Count cancel invocations. #[must_use] pub fn cancel_calls(&self) -> usize { self.inner @@ -614,6 +662,7 @@ impl MockAudioRecorder { } } +/// Mock text injector that records injected payloads for assertions. #[derive(Debug, Clone)] pub struct MockTextInjector { inner: Arc>, @@ -633,6 +682,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 +690,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 +699,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 +709,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 ff7f6e2..d02ec92 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,9 +105,6 @@ fn route_envelope( } fn non_empty_text(text: &Option) -> Option<&str> { - // Treat whitespace-only text as absent so routing falls back to a usable - // counterpart instead of injecting blank-looking output. STT and refine paths - // use the same trim-based emptiness, so this keeps routing consistent with them. text.as_deref().filter(|value| !value.trim().is_empty()) } 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..b120904 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() @@ -81,4 +103,4 @@ mod tests { PermissionPreflightStatus::unsupported() ); } -} +} \ No newline at end of file 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 0b78ab5..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, @@ -357,11 +378,6 @@ fn replay_refine_context(config: &AppConfig) -> Option { return None; } - // Redact the prompt text. The README promises full-debug replay redacts prompt - // fields, and config/envelope snapshots already strip system_prompt; persisting - // the verbatim refine prompt here would leak private vocabulary, names, and - // project hints into shared/backed-up replay directories. Keep the field present - // as a marker so the artifact still records that a refine prompt was configured. Some(ReplayRefineContext { system_prompt: REDACTED_VALUE.to_string(), }) @@ -1109,8 +1125,6 @@ mod tests { ) .expect("parse replay record"); - // refine_context is present (a non-empty refine prompt was configured) but - // the prompt text itself is redacted, matching the README redaction promise. assert!(!config.transcript.system_prompt.trim().is_empty()); assert_eq!( record["refine_context"]["system_prompt"], diff --git a/src/replay_dispatch.rs b/src/replay_dispatch.rs index a14862b..93e49f6 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,21 +25,20 @@ 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() { - // The worker owns the temp WAV for this request. Capture its path so - // we can delete it after persistence (and thus after audio retention) - // regardless of which branch persist_request returns through. let wav_path = request.recorded.wav_path.clone(); persist_request(&mut stores, request); crate::runtime_pipeline::cleanup_recording_file(&wav_path); @@ -46,14 +51,11 @@ impl ReplayPersistenceService { } } - /// Hand a replay persistence request to the background worker. + /// Queue replay persistence for one utterance. /// - /// Returns `true` when the worker has accepted the request and thereby taken - /// ownership of the recorded temp WAV: the worker deletes it only after audio - /// retention has had a chance to copy it. Returns `false` when replay is - /// disabled or the request was dropped, in which case the caller remains - /// responsible for deleting the WAV. This ordering prevents a race where the - /// runtime deletes the temp WAV before the async worker can retain its audio. + /// Returns `true` when the worker accepted the request and will delete the + /// temporary WAV after writing. Returns `false` when replay is disabled, the + /// queue is full, or the worker is unavailable. pub(crate) fn enqueue( &self, resolved: Option, diff --git a/src/runner.rs b/src/runner.rs index 6064c1d..fa6d27b 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, + /// Child process exit code when the step ran externally; `None` on timeout. 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 870787b..67bb871 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; @@ -7,6 +13,7 @@ use tokio::process::Command; use tokio::task::JoinHandle; use tokio::time::timeout; +/// Successful child process completion with capped stream captures. #[derive(Debug)] pub(super) struct CommandResult { pub(super) stdout: CapturedOutput, @@ -15,34 +22,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, } -/// Which phase of the bounded write/wait future failed, so the outer timeout can -/// map it back to the right [`CommandErrorKind`]. 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, @@ -52,6 +71,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], @@ -100,19 +124,11 @@ pub(super) async fn run_command( stderr: CapturedOutput::default(), })?; - // Spawn the stdout/stderr drains BEFORE writing stdin. A child that emits - // output while consuming input would otherwise fill its stdout pipe, stop - // reading stdin, and deadlock the parent's write_all. Draining concurrently - // keeps the child unblocked so the write can make progress. let stdout_reader = tokio::spawn(async move { read_to_end_capped(stdout, max_stdout_bytes).await }); let stderr_reader = tokio::spawn(async move { read_to_end_capped(stderr, max_stderr_bytes).await }); - // Drive the stdin write/shutdown and child.wait() under a single - // timeout_budget. Previously the write phase ran outside any timeout and with - // no concurrent stdout drain, so a child that never reads stdin to EOF (or - // stalls) blocked write_all forever and the timeout was never reached. let write_and_wait = async { stdin .write_all(stdin_bytes) @@ -288,15 +304,10 @@ fn format_command_error_details(primary: String, cleanup_notes: Vec) -> mod tests { use super::*; - // Far larger than any OS pipe buffer (~64 KB), so a child that reads and writes - // concurrently, or that never reads stdin, exposes back-pressure deadlocks. 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() { - // `cat` echoes stdin to stdout. If stdin were fully written before the - // stdout reader was spawned, cat would fill its stdout pipe, stop reading - // stdin, and the parent's write_all would deadlock. let payload = vec![b'x'; LARGE_PAYLOAD_BYTES]; let result = run_command( @@ -317,9 +328,6 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn times_out_when_child_never_reads_stdin() { - // `sleep` never reads stdin, so a payload larger than the pipe buffer blocks - // the parent's write_all. The write phase must now be bounded by - // timeout_budget instead of hanging forever. let payload = vec![b'x'; LARGE_PAYLOAD_BYTES]; let error = run_command( 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 21b1627..65fca52 100644 --- a/src/runtime_permissions.rs +++ b/src/runtime_permissions.rs @@ -1,3 +1,9 @@ +//! macOS TCC permission refresh before recording and injection. +//! +//! Hotkey recording requires Input Monitoring; tray and external controls use a +//! lighter preflight. Prompting during a user gesture may grant access but still +//! aborts that gesture so the user retries with stable permissions. + use anyhow::{anyhow, Result}; use muninn::{PermissionKind, PermissionPreflightStatus, PermissionStatus, PermissionsAdapter}; use tracing::{info, warn}; @@ -14,20 +20,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 +51,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 +105,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 +147,11 @@ 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. Hotkey +/// Input Monitoring prompts always abort; tray starts continue after IM prompts. pub(crate) fn should_abort_recording_start( preflight: PermissionPreflightStatus, requested_microphone: bool, @@ -195,6 +223,10 @@ 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, @@ -202,11 +234,6 @@ pub(crate) fn should_abort_injection( match ensure_injection_allowed(preflight) { Ok(()) => { if requested_accessibility { - // Accessibility was requested this interaction and the refreshed - // preflight now reports it granted (AXIsProcessTrusted is true), so - // injection will succeed. Proceed instead of aborting: aborting here - // discards an already-transcribed utterance and deletes its WAV with - // no retry path, forcing the user to re-dictate. info!( target: logging::TARGET_RUNTIME, "Accessibility access was granted during this interaction; proceeding with injection" @@ -244,6 +271,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 9f9b4c1..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,11 +62,6 @@ where replay_persist, streaming_outcome, } = context; - // When the replay worker accepts the request it takes ownership of the temp - // WAV and deletes it only after audio retention completes. Otherwise this - // function must delete the WAV itself (see cleanup below). Default to false so - // that any early return — including an injection error after enqueue — still - // leaves cleanup with whoever actually owns the file. let mut worker_owns_wav = false; let result = async { let envelope = build_envelope(resolved, recorded, streaming_outcome); @@ -168,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, @@ -175,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 { @@ -186,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) @@ -554,6 +574,7 @@ fn resolve_step_command(cmd: &str) -> Result { Ok(cmd.to_string()) } +/// 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!( diff --git a/src/runtime_shell.rs b/src/runtime_shell.rs index c13666e..ed6df36 100644 --- a/src/runtime_shell.rs +++ b/src/runtime_shell.rs @@ -1,3 +1,10 @@ +//! 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; use anyhow::{Context, Result}; @@ -21,6 +28,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 +37,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 +63,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(); @@ -68,13 +85,6 @@ impl AppRuntime { // macOS delivers the launch GetURL Apple Event during // applicationWillFinishLaunching, earlier than StartCause::Init, so the // observer set up here must already be in place to catch cold-launch URLs. - // - // The handler is always installed, but URL dispatch is gated on the - // runtime url_scheme_enabled flag (seeded here and refreshed on every live - // config reload). This makes url_scheme_enabled a real runtime control - // surface: disabling it via reload immediately stops external URL control - // even though the Apple Event handler stays registered for the process - // lifetime. #[cfg(target_os = "macos")] { crate::external_control::set_url_scheme_enabled( @@ -225,8 +235,6 @@ impl AppRuntime { } Event::UserEvent(UserEvent::ConfigReloaded(config)) => { current_config = (*config).clone(); - // Refresh the URL-scheme gate so disabling (or re-enabling) - // url_scheme_enabled via live reload takes effect immediately. #[cfg(target_os = "macos")] crate::external_control::set_url_scheme_enabled( current_config.external_control.url_scheme_enabled, 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 728ef7c..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,8 +412,6 @@ fn looks_like_acronym(text: &str) -> bool { } fn non_empty_text(text: &Option) -> Option<&str> { - // Match orchestrator/STT/refine: whitespace-only text counts as absent so a - // blank final_text does not suppress scoring of a usable raw transcript. text.as_deref().filter(|value| !value.trim().is_empty()) } diff --git a/src/secrets.rs b/src/secrets.rs index 2e43476..9722678 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) @@ -96,4 +109,4 @@ mod tests { assert_eq!(resolved.as_deref(), Some("config-fallback")); } -} +} \ No newline at end of file diff --git a/src/state.rs b/src/state.rs index ec926f0..c9c1c18 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::*; @@ -132,4 +156,4 @@ mod tests { AppState::Injecting ); } -} +} \ No newline at end of file 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 ed49b97..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; @@ -24,6 +30,7 @@ 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; @@ -233,11 +240,6 @@ where self.socket.send(Message::Pong(payload)).await?; } Message::Close(frame) => { - // A server-initiated error close (e.g. 1008/4xxx - // "insufficient_credits", auth/policy failure) must be a provider - // failure, not an empty-success outcome reported as "no speech - // detected". Benign closes (1000 Normal, 1001 Going Away, or no - // frame) just end the loop. if let Some(frame) = frame { let code = u16::from(frame.code); if !matches!(code, 1000 | 1001) { @@ -257,8 +259,6 @@ where } let outcome = self.transcript.into_outcome(); - // Prefer a transcript produced before an error close; only surface the close - // as a failure when there is no usable final text. match error_close { Some(error) if !outcome.has_final_text() => Err(error), _ => Ok(outcome), 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 5eca2f0..6e7cfef 100644 --- a/src/streaming_transcription/openai.rs +++ b/src/streaming_transcription/openai.rs @@ -1,3 +1,10 @@ +//! 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}; @@ -28,6 +35,7 @@ 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; @@ -277,14 +285,6 @@ where )) .await?; - // The realtime session is persistent and does not close after a single - // utterance. The server acknowledges this commit with the user item id - // (`input_audio_buffer.committed`) and later finalizes the item with its - // audio content indexes (`conversation.item.done`). Wait until each audio - // content part of that committed item has a completed transcript instead of - // using a fixed post-completion grace, which can truncate delayed final - // events. The upstream finish timeout remains the outer bound for a missing - // acknowledgement/finalization/completion. let mut error_close: Option = None; loop { let Some(message) = self.socket.next().await? else { @@ -303,10 +303,6 @@ where self.socket.send(Message::Pong(payload)).await?; } Message::Close(frame) => { - // A server-initiated error close (auth/quota/policy) must be a - // provider failure, not an empty-success outcome that would be - // reported to the user as "no speech detected". Benign closes - // (1000 Normal, 1001 Going Away, or no frame) just end the loop. if let Some(frame) = frame { let code = u16::from(frame.code); if !matches!(code, 1000 | 1001) { @@ -327,8 +323,6 @@ where let _ = self.socket.close().await; let outcome = self.transcript.into_outcome(); - // Prefer a transcript that was produced before an error close; only surface - // the close as a failure when there is no usable final text. match error_close { Some(error) if !outcome.has_final_text() => Err(error), _ => Ok(outcome), @@ -1007,7 +1001,6 @@ api_key = "config-key" .await .expect("session should finalize"); - // Regression: finish() must not stop after the first completed segment. assert_eq!( outcome.raw_text.as_deref(), Some("first segment second segment") @@ -1042,8 +1035,6 @@ api_key = "config-key" .await .expect("session should wait for the committed item's delayed completion"); - // Regression: a fixed post-completion grace can close the persistent socket - // before a delayed final event arrives. assert_eq!(outcome.raw_text.as_deref(), Some("delayed final text")); } diff --git a/src/stt_apple_speech_tool.rs b/src/stt_apple_speech_tool.rs index 59e528e..3d8b80f 100644 --- a/src/stt_apple_speech_tool.rs +++ b/src/stt_apple_speech_tool.rs @@ -1,3 +1,9 @@ +//! 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}; @@ -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, @@ -615,11 +629,6 @@ fn resolved_config_from_builtin_steps( fn materialize_helper_binary() -> Result { static HELPER_INIT: Mutex<()> = Mutex::new(()); - // Re-validate the on-disk helper against the embedded bytes on every call - // rather than caching the path. A cached path would let a same-user attacker - // replace the materialized binary after first use and have Muninn execute the - // tampered file on the next dictation (TOCTOU). The mutex serializes concurrent - // refreshes so two callers never stage/rename the helper at the same time. let _guard = HELPER_INIT.lock().map_err(|_| { CliError::new( "apple_speech_helper_lock_failed", 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 b080c5a..6d748cd 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, @@ -592,12 +606,6 @@ fn extract_google_transcript_text(body: &[u8]) -> Result { ) })?; - // Google's speech:recognize returns one `results` entry per consecutive audio - // segment for longer audio; the full transcript is the concatenation of each - // segment's top alternative. Aggregate all results (taking alternatives[0] per - // result) rather than keeping only the first segment, matching the Whisper and - // Deepgram adapters. Segment transcripts carry their own leading spaces, so we - // concatenate without a separator and trim the joined string. let transcript = value .get("results") .and_then(Value::as_array) @@ -970,8 +978,6 @@ mod tests { #[test] fn response_json_concatenates_multiple_result_segments() { - // Regression: longer audio returns one `results` entry per segment; the - // full transcript is their concatenation, not just the first segment. let transcript = extract_google_transcript_text( br#"{"results":[{"alternatives":[{"transcript":"hello world"}]},{"alternatives":[{"transcript":" goodbye now"}]}]}"#, ) @@ -981,7 +987,6 @@ mod tests { #[test] fn response_json_uses_first_alternative_per_result() { - // Only the top alternative of each result contributes to the transcript. let transcript = extract_google_transcript_text( br#"{"results":[{"alternatives":[{"transcript":"primary","confidence":0.9},{"transcript":"secondary","confidence":0.4}]}]}"#, ) 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..0d09390 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")] @@ -172,4 +189,4 @@ fn normalize_optional_string(value: Option) -> Option { fn normalize_string(value: String) -> Option { normalize_optional_string(Some(value)) -} +} \ No newline at end of file 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 Date: Fri, 26 Jun 2026 21:43:41 +0100 Subject: [PATCH 15/16] Delete .devana directory --- ...5T083533Z-P0-apple-speech-helper-toctou.md | 59 ------------ ...P1-accessibility-grant-loses-transcript.md | 56 ----------- ...-P1-replay-audio-deleted-before-persist.md | 57 ------------ ...6Z-P1-replay-prompt-leak-refine-context.md | 54 ----------- ...83537Z-P2-whitespace-only-text-injected.md | 54 ----------- ...5T083538Z-P2-external-toggle-start-gate.md | 57 ------------ ...9Z-P2-url-scheme-disable-ignored-reload.md | 55 ----------- ...-P2-openai-streaming-truncates-segments.md | 76 --------------- ...Z-P2-external-step-stdin-write-deadlock.md | 92 ------------------- ...P2-google-batch-multisegment-truncation.md | 86 ----------------- ...1240Z-P3-streaming-error-close-as-empty.md | 89 ------------------ 11 files changed, 735 deletions(-) delete mode 100644 .devana/20260625T083533Z-P0-apple-speech-helper-toctou.md delete mode 100644 .devana/20260625T083534Z-P1-accessibility-grant-loses-transcript.md delete mode 100644 .devana/20260625T083535Z-P1-replay-audio-deleted-before-persist.md delete mode 100644 .devana/20260625T083536Z-P1-replay-prompt-leak-refine-context.md delete mode 100644 .devana/20260625T083537Z-P2-whitespace-only-text-injected.md delete mode 100644 .devana/20260625T083538Z-P2-external-toggle-start-gate.md delete mode 100644 .devana/20260625T083539Z-P2-url-scheme-disable-ignored-reload.md delete mode 100644 .devana/20260625T083540Z-P2-openai-streaming-truncates-segments.md delete mode 100644 .devana/20260625T091238Z-P2-external-step-stdin-write-deadlock.md delete mode 100644 .devana/20260625T091239Z-P2-google-batch-multisegment-truncation.md delete mode 100644 .devana/20260625T091240Z-P3-streaming-error-close-as-empty.md diff --git a/.devana/20260625T083533Z-P0-apple-speech-helper-toctou.md b/.devana/20260625T083533Z-P0-apple-speech-helper-toctou.md deleted file mode 100644 index 4f89417..0000000 --- a/.devana/20260625T083533Z-P0-apple-speech-helper-toctou.md +++ /dev/null @@ -1,59 +0,0 @@ -DEVANA-FINDING: v1 -Priority: P0 | Confidence: high | Security-sensitive: yes | Status: fixed -Location: src/stt_apple_speech_tool.rs:615-653 | Slug: apple-speech-helper-toctou - -# Apple Speech helper skips integrity check after first materialization - -## Finding - -The embedded Apple Speech helper binary is written to a predictable path under the user temp directory and integrity-checked only on first materialization. After `HELPER_PATH` is cached in a `OnceLock`, later transcriptions return the cached path without re-running `helper_needs_refresh()`, so a same-user attacker can replace the on-disk helper and have Muninn execute it on the next dictation. - -## Violated Invariant Or Contract - -A binary executed with envelope stdin (including `audio.wav_path`) should match the embedded bytes Muninn shipped, on every invocation. - -## Oracle - -`materialize_helper_binary()` fast-path at lines 619-621 and 630-632; `helper_needs_refresh()` only runs before first `HELPER_PATH.set()`; `invoke_apple_speech_helper()` spawns `Command::new(&helper_path)` at line 334. - -## Counterexample - -1. User runs Muninn and completes one Apple Speech transcription; helper is materialized at `$TMPDIR/muninn/embedded-tools/apple-speech-transcriber---`. -2. Same-user malware replaces that file with a trojan binary (path is predictable from version constants). -3. User dictates again with Apple Speech in the route. -4. `materialize_helper_binary()` returns the cached path without re-reading bytes. -5. Muninn spawns the trojan with serialized envelope JSON on stdin. - -## Why It Might Matter - -Arbitrary code execution as the Muninn user during normal dictation, with access to temp WAV paths and envelope contents. No malicious pipeline config or external-control interaction is required. - -## Proof - -Control-flow trace: first transcription → `helper_needs_refresh` + `write_helper_atomically` → `HELPER_PATH.set` → subsequent calls skip refresh → `Command::new(helper_path).spawn()`. - -Counterexample value: replaced bytes at predictable `helper_output_path()` after first successful materialization. - -## Counterevidence Checked - -`write_helper_atomically` stages via a temp file and `ensure_helper_permissions` sets executable bits, but neither re-validates content on later runs. `HELPER_INIT` mutex only prevents concurrent first init, not post-cache tampering. - -## Suggested Next Step - -Re-run `helper_needs_refresh()` (or equivalent signature check) before every spawn, or execute from a non-user-writable location with immutable permissions after install. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-26: fixed. The cached helper path fast path is gone. Both sync and - async helper execution call `materialize_helper_binary()` immediately before - spawning the helper, and `materialize_helper_binary()` now reads the on-disk - helper on every call via `helper_needs_refresh(&path)`, rewrites mismatched - bytes, ensures executable permissions, and only then returns the path. The - original "replace the cached helper after first materialization and wait for a - later dictation" counterexample is blocked. Residual checked: there is still a - narrower check-to-spawn race after per-call validation, but that is not the - cached-path bug reported here. - -DEVANA-KEY: src/stt_apple_speech_tool.rs:615-653 | P0 | apple-speech-helper-toctou -DEVANA-SUMMARY: Status=fixed | P0 high src/stt_apple_speech_tool.rs:615-653 - Cached Apple Speech helper path skips re-verification, allowing same-user replacement and arbitrary code execution on later transcriptions. diff --git a/.devana/20260625T083534Z-P1-accessibility-grant-loses-transcript.md b/.devana/20260625T083534Z-P1-accessibility-grant-loses-transcript.md deleted file mode 100644 index 7e5a378..0000000 --- a/.devana/20260625T083534Z-P1-accessibility-grant-loses-transcript.md +++ /dev/null @@ -1,56 +0,0 @@ -DEVANA-FINDING: v1 -Priority: P1 | Confidence: high | Security-sensitive: no | Status: fixed -Location: src/runtime_pipeline.rs:87-91,143-144 | Slug: accessibility-grant-loses-transcript - -# Accessibility grant during first injection silently discards completed transcript - -## Finding - -When the user grants Accessibility during the first injection attempt, `should_abort_injection` returns true even though permissions are now granted. `process_and_inject` returns `Ok(())` without injecting, then unconditionally transitions to `InjectionFinished` and deletes the temp WAV. The log tells the user to "retry the injection action," but no retry path exists for the already-processed utterance. - -## Violated Invariant Or Contract - -A successful pipeline with injectable text should either inject the text or surface a recoverable failure. Aborting after the user grants the required permission should not discard the transcript and recording artifact. - -## Oracle - -`should_abort_injection` at `runtime_permissions.rs:202-208` aborts when `requested_accessibility` is true even if `ensure_injection_allowed` succeeds; unit test `injection_aborts_after_accessibility_prompt_even_when_now_granted` in `main.rs:558-562` encodes this branch. `process_and_inject` always calls `cleanup_recording_file` after `InjectionFinished` (`runtime_pipeline.rs:143-144`). - -## Counterexample - -1. User dictates; pipeline completes with `route.target.text() == Some("cargo test -q")`. -2. Accessibility was `NotDetermined`; `refresh_injection_permissions_for_user_action` prompts and user grants. -3. `should_abort_injection(all_granted(), true)` returns true. -4. Function returns `Ok(())` at line 91 without calling `inject_checked`. -5. State moves to Idle; WAV deleted. User must re-dictate the entire utterance. - -## Why It Might Matter - -First-time macOS setup is common (README documents Accessibility prompt on first injection). Users who grant access during that prompt lose a fully transcribed utterance with no indicator that re-dictation is required. - -## Proof - -Control-flow trace: `ProcessingFinished` → permission refresh with prompt → `should_abort_injection` true → early `return Ok(())` → `InjectionFinished` → `cleanup_recording_file`. - -Cross-entry mismatch: recording-start abort happens before pipeline work; injection abort happens after pipeline success, so the retry-gesture pattern has different data-loss impact. - -## Counterevidence Checked - -`MissingCredentials` indicator path exists for empty injectable text, not for this permission branch. No queue or retained envelope for a follow-up injection gesture. - -## Suggested Next Step - -After a successful Accessibility grant in the same interaction, proceed with `inject_checked` instead of aborting, or retain the injection text and expose an explicit retry that does not require re-recording. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-26: fixed. `should_abort_injection` now returns `false` when the - refreshed preflight allows injection, even if Accessibility was requested in - the same interaction. `process_and_inject` therefore proceeds to - `inject_checked` after a successful grant instead of returning before - injection. If Accessibility remains denied, the abort path is still used. The - original grant-then-discard counterexample is blocked. - -DEVANA-KEY: src/runtime_pipeline.rs:87-91,143-144 | P1 | accessibility-grant-loses-transcript -DEVANA-SUMMARY: Status=fixed | P1 high src/runtime_pipeline.rs:87-91,143-144 - Granting Accessibility on first injection aborts silently and deletes the WAV, losing a completed transcript despite the retry log message. diff --git a/.devana/20260625T083535Z-P1-replay-audio-deleted-before-persist.md b/.devana/20260625T083535Z-P1-replay-audio-deleted-before-persist.md deleted file mode 100644 index f43398b..0000000 --- a/.devana/20260625T083535Z-P1-replay-audio-deleted-before-persist.md +++ /dev/null @@ -1,57 +0,0 @@ -DEVANA-FINDING: v1 -Priority: P1 | Confidence: high | Security-sensitive: no | Status: fixed -Location: src/runtime_pipeline.rs:76,143-144 | Slug: replay-audio-deleted-before-persist - -# Replay audio retention races temp WAV deletion - -## Finding - -Replay persistence is enqueued asynchronously via `try_send` to a background worker, but the runtime worker deletes the temp WAV synchronously immediately after injection finishes. When `replay_detail = "full_debug"` and `replay_retain_audio = true`, the background worker often finds the source file missing and silently skips audio retention. - -## Violated Invariant Or Contract - -When replay audio retention is enabled, the persisted artifact should include the recorded audio for the utterance that just completed. - -## Oracle - -`replay_persist.enqueue` is non-blocking (`replay_dispatch.rs:124-131`); `cleanup_recording_file` runs on the worker thread at `runtime_pipeline.rs:143-144` before persist is guaranteed; `retain_audio_if_available` returns `Ok(None)` when `!recorded.wav_path.exists()` (`replay.rs:495-497`). - -## Counterexample - -1. User enables `replay_enabled`, `replay_detail = "full_debug"`, `replay_retain_audio = true`. -2. Utterance completes; `enqueue` succeeds but worker is still processing prior artifacts or not yet scheduled. -3. Main thread reaches `cleanup_recording_file(&recorded.wav_path)`. -4. Worker later calls `retain_audio_if_available`; file is gone → no `audio.*` artifact, no hard error surfaced to the user. - -## Why It Might Matter - -Full-debug replay is explicitly for diagnosing utterance issues; missing audio undermines the primary debugging value of retained recordings, including on normal shutdown paths (not only crash). - -## Proof - -Dataflow trace: `RecordedAudio.wav_path` → async enqueue → synchronous delete → `retain_audio_if_available` miss. - -Related: queue capacity 8 drops entire persist requests on overflow (`replay_dispatch.rs:126-131`), compounding artifact loss under burst load. - -## Counterevidence Checked - -Hard-link/copy logic in `retain_audio_file` works when the source still exists. Synchronous persist is not used. No ordering guarantee between enqueue and cleanup. - -## Suggested Next Step - -Retain or copy audio before deleting the temp WAV (or block cleanup until persist acknowledges audio retention). - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-26: fixed. `ReplayPersistenceService::enqueue` now returns `true` - only after the worker queue accepts the replay request, which transfers temp - WAV ownership to the replay worker. `process_and_inject` stores that result in - `worker_owns_wav` and skips main-thread cleanup when the worker owns the file; - the worker deletes the WAV only after `persist_request` returns, so audio - retention runs before cleanup. If replay is disabled or enqueue fails, the - caller still owns and deletes the WAV. The original accepted-enqueue versus - immediate-cleanup race is blocked. - -DEVANA-KEY: src/runtime_pipeline.rs:76,143-144 | P1 | replay-audio-deleted-before-persist -DEVANA-SUMMARY: Status=fixed | P1 high src/runtime_pipeline.rs:76,143-144 - Async replay enqueue races synchronous WAV cleanup, so full-debug audio retention is often silently dropped. diff --git a/.devana/20260625T083536Z-P1-replay-prompt-leak-refine-context.md b/.devana/20260625T083536Z-P1-replay-prompt-leak-refine-context.md deleted file mode 100644 index 9aacafa..0000000 --- a/.devana/20260625T083536Z-P1-replay-prompt-leak-refine-context.md +++ /dev/null @@ -1,54 +0,0 @@ -DEVANA-FINDING: v1 -Priority: P1 | Confidence: high | Security-sensitive: yes | Status: fixed -Location: src/replay.rs:349-362 | Slug: replay-prompt-leak-refine-context - -# Full-debug replay persists refine prompts despite documented redaction - -## Finding - -README states that full-debug replay snapshots redact provider secrets and prompt fields. Runtime clears `system_prompt` from sanitized envelopes and redacts prompt keys in config snapshots, but `replay_refine_context` writes the full materialized `transcript.system_prompt` into `record.json` under `refine_context.system_prompt`. A unit test explicitly expects this behavior. - -## Violated Invariant Or Contract - -Full-debug replay artifacts should not persist prompt fields when documentation promises prompt redaction. - -## Oracle - -README line 539: "full-debug replay snapshots redact provider secrets and prompt fields"; `replay_refine_context` at `replay.rs:349-362` copies `config.transcript.system_prompt` verbatim; test `persist_replay_includes_system_prompt_only_in_refine_context_when_refine_is_present` at `replay.rs:1060-1118` asserts the prompt is present in `refine_context`. - -## Counterexample - -1. User sets `replay_detail = "full_debug"` with custom `system_prompt` or `system_prompt_append` containing vocabulary JSON, names, or project hints. -2. Pipeline includes a `refine` step. -3. `persist_replay` writes `record.json` with `refine_context.system_prompt` containing the full hint text. -4. Envelope and config snapshots have prompts cleared/redacted, but the refine context field still leaks the material. - -## Why It Might Matter - -Replay directories may be shared, backed up, or inspected on less-trusted machines. Prompts can contain private vocabulary, contact names, internal project terms, and stylistic instructions the user believed were redacted. - -## Proof - -Contract mismatch: README redaction promise vs `replay_refine_context` writer and its regression test. - -Dataflow trace: live `AppConfig.transcript.system_prompt` → `replay_refine_context` → `record.json` while `sanitized_envelope_for_replay` clears envelope prompts. - -## Counterevidence Checked - -`is_prompt_config_key` redacts `system_prompt` / `system_prompt_append` in config JSON snapshots. `sanitize_envelope_in_place` clears envelope `transcript.system_prompt`. No redaction applied to `ReplayRefineContext.system_prompt`. - -## Suggested Next Step - -Omit `refine_context.system_prompt` in full-debug mode (or apply the same redaction policy as envelope/config snapshots) and update the regression test accordingly. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-26: fixed. `replay_refine_context` now preserves the - `refine_context.system_prompt` marker only as `[redacted]` instead of copying - the configured prompt text. Config snapshots still remove prompt keys, and - envelope/pipeline-outcome replay sanitization still clears transcript system - prompts. The original full-debug `record.json` prompt leak is blocked. - -DEVANA-KEY: src/replay.rs:349-362 | P1 | replay-prompt-leak-refine-context -DEVANA-SUMMARY: Status=fixed | P1 high src/replay.rs:349-362 - Full-debug replay writes refine prompts into record.json despite README claiming prompt fields are redacted. diff --git a/.devana/20260625T083537Z-P2-whitespace-only-text-injected.md b/.devana/20260625T083537Z-P2-whitespace-only-text-injected.md deleted file mode 100644 index c87a1af..0000000 --- a/.devana/20260625T083537Z-P2-whitespace-only-text-injected.md +++ /dev/null @@ -1,54 +0,0 @@ -DEVANA-FINDING: v1 -Priority: P2 | Confidence: high | Security-sensitive: no | Status: fixed -Location: src/orchestrator.rs:84-86 | Slug: whitespace-only-text-injected - -# Whitespace-only pipeline text is treated as injectable - -## Finding - -Injection routing treats any non-zero-length string as injectable text. Whitespace-only `output.final_text` or `transcript.raw_text` wins over a usable counterpart field, and `inject_checked` accepts whitespace because it only rejects `text.is_empty()`, not trimmed emptiness. Downstream STT and refine paths use trim-based emptiness checks, so behavior is inconsistent. - -## Violated Invariant Or Contract - -Injectable text should be semantically non-empty. When `output.final_text` is whitespace-only, injection should fall back to `transcript.raw_text` or inject nothing. - -## Oracle - -`non_empty_text` in `orchestrator.rs:84-86` uses `!value.is_empty()` without trim; `inject_checked` in `lib.rs:411-415` mirrors that; STT tools use trim checks (e.g. `stt_whisper_cpp_tool.rs` `has_non_empty_raw_text`); `scoring.rs:224-226` shares the same non-trimming helper. - -## Counterexample - -Pipeline completes with `output.final_text = Some(" \n\t")` and `transcript.raw_text = Some("ship to San Francisco")`. `Orchestrator::route_injection` selects `OutputFinalText(" \n\t")`. `inject_checked` succeeds and types whitespace into the focused app instead of the transcript. - -A text-filter pipeline step can also write untrimmed stdout to `transcript.raw_text` via `runner/codec.rs`, producing the same routing outcome. - -## Why It Might Matter - -User-visible wrong injection (blank-looking output) or loss of a usable transcript when a downstream step leaves whitespace in `final_text`. - -## Proof - -Control-flow trace: `route_envelope` prefers `output.final_text` → `non_empty_text` accepts whitespace → `inject_checked` proceeds. - -Counterexample value: `" \n\t"` in `output.final_text` with non-empty `transcript.raw_text`. - -## Counterevidence Checked - -Refine acceptance trims and rejects empty candidate output (`refine.rs:411-420`). Default STT steps write trimmed transcripts, so this typically requires a downstream text-filter or manual envelope mutation. - -## Suggested Next Step - -Align `non_empty_text` and `inject_checked` with trim-based emptiness used by STT/refine, or treat whitespace-only `final_text` as absent for routing. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-26: fixed. `orchestrator::non_empty_text` now treats - whitespace-only strings as absent by checking `!value.trim().is_empty()`, so a - blank `output.final_text` falls back to usable `transcript.raw_text` or no - injection. `TextInjector::inject_checked` also rejects trim-empty text before - calling the platform injector. The original blank-looking injection - counterexample is blocked. - -DEVANA-KEY: src/orchestrator.rs:84-86 | P2 | whitespace-only-text-injected -DEVANA-SUMMARY: Status=fixed | P2 high src/orchestrator.rs:84-86 - Whitespace-only final_text is preferred over a real transcript and passes inject_checked, causing blank injection. diff --git a/.devana/20260625T083538Z-P2-external-toggle-start-gate.md b/.devana/20260625T083538Z-P2-external-toggle-start-gate.md deleted file mode 100644 index c94566e..0000000 --- a/.devana/20260625T083538Z-P2-external-toggle-start-gate.md +++ /dev/null @@ -1,57 +0,0 @@ -DEVANA-FINDING: v1 -Priority: P2 | Confidence: high | Security-sensitive: no | Status: stale -Location: src/external_control/action.rs:45-53 | Slug: external-toggle-start-gate - -# External toggle incorrectly gated by start_recording_enabled when idle - -## Finding - -README documents that only `start` requires `start_recording_enabled = true`, while `toggle` should start recording when idle regardless of that flag. Runtime applies the same idle gate to `Toggle` as to `Start`, so external `muninn://toggle` and MCP-equivalent paths cannot start capture when the flag is false, even though tray toggle still works via `to_app_event(..., true)`. - -## Violated Invariant Or Contract - -External `toggle` when idle should begin recording without requiring `start_recording_enabled`, matching README action semantics. - -## Oracle - -README lines 285-287: `start` is gated; `toggle` "starts when idle, otherwise stops the active recording" with no `start_recording_enabled` mention. `ExternalControlAction::Toggle` at `action.rs:45-53` returns `Disabled` when idle and `!start_recording_enabled`. `runtime_worker.rs:218-232` drops disabled external starts with a log. - -## Counterexample - -1. Default config: `start_recording_enabled = false`. -2. Agent opens `muninn://toggle` while Muninn is idle. -3. `resolve` returns `ExternalControlOutcome::Disabled`. -4. Worker logs "external recording start blocked" and continues; no recording starts. -5. Tray click toggle still starts recording because `to_app_event` hardcodes `start_recording_enabled = true`. - -## Why It Might Matter - -Automation agents documented to use `toggle` cannot start capture unless operators also enable the stricter `start_recording_enabled` opt-in meant for `start` only. - -## Proof - -Contract mismatch between README external-control semantics and `ExternalControlAction::Toggle` idle branch. - -Cross-entry mismatch: tray `Toggle` vs URL/MCP `Toggle` under the same config. - -## Counterevidence Checked - -`Start` correctly gated. Stop/cancel/toggle-while-recording paths do not use the start gate. This is not the documented MCP `start_recording_enabled` launch snapshot issue. - -## Suggested Next Step - -Split toggle idle handling from the `start_recording_enabled` check, or update README if the gate is intentional. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-26: stale. The behavior was not changed to allow idle external - `toggle` without the microphone-start opt-in; instead the current contract now - explicitly says idle `toggle` is subject to the same - `start_recording_enabled = true` gate as `start`. `ExternalControlAction` and - its tests encode that gate. The original report's README/code mismatch no - longer exists, so this is not a current runtime defect under the documented - semantics. - -DEVANA-KEY: src/external_control/action.rs:45-53 | P2 | external-toggle-start-gate -DEVANA-SUMMARY: Status=stale | P2 high src/external_control/action.rs:45-53 - External idle toggle is now documented and tested as gated by start_recording_enabled, so the original README/code mismatch no longer applies. diff --git a/.devana/20260625T083539Z-P2-url-scheme-disable-ignored-reload.md b/.devana/20260625T083539Z-P2-url-scheme-disable-ignored-reload.md deleted file mode 100644 index fd449d2..0000000 --- a/.devana/20260625T083539Z-P2-url-scheme-disable-ignored-reload.md +++ /dev/null @@ -1,55 +0,0 @@ -DEVANA-FINDING: v1 -Priority: P2 | Confidence: high | Security-sensitive: yes | Status: fixed -Location: src/runtime_shell.rs:72-74, src/external_control/url_scheme.rs:51-72 | Slug: url-scheme-disable-ignored-reload - -# url_scheme_enabled=false after live reload leaves handler active - -## Finding - -The `muninn://` Apple Event handler is installed only at bootstrap when `url_scheme_enabled` is true. Live config reload updates in-memory config but never unregisters the handler or checks the flag when URLs arrive. Disabling `url_scheme_enabled` at runtime therefore does not stop external URL control. - -## Violated Invariant Or Contract - -When `external_control.url_scheme_enabled = false`, `muninn://` verbs should not reach the runtime worker. - -## Oracle - -`install_url_scheme_handler` gated at `runtime_shell.rs:72-74`; `handle_get_url_event` forwards every parsed verb without reading config (`url_scheme.rs:67-71`); config reload path updates worker config (`runtime_worker.rs` `ReloadConfig`) but has no URL handler lifecycle. README presents `url_scheme_enabled` as the control surface (line 276) without stating it is launch-only (unlike `mcp_enabled`). - -## Counterexample - -1. Launch Muninn with `url_scheme_enabled = true`. -2. Operator edits config to `url_scheme_enabled = false`; watcher emits `ConfigReloaded`. -3. Worker applies new config; handler remains registered. -4. `open "muninn://stop"` while a recording is active still dispatches `ExternalControl(Stop)` and can finish capture and run the pipeline. - -## Why It Might Matter - -Operators who disable URL control via config reload believe external agents can no longer drive Muninn, but stop/toggle/cancel remain reachable from any local app that can open `muninn://` links. - -## Proof - -Control-flow trace: bootstrap install (once) → reload mutates config only → `handle_get_url_event` unconditional forward. - -## Counterevidence Checked - -`start_recording_enabled` is enforced on the worker reload path for start/toggle-when-idle, but stop/cancel are not gated by that flag. No `url_scheme_enabled` read exists outside initial install. - -## Suggested Next Step - -Consult `url_scheme_enabled` on each URL event, or unregister/re-register the handler when the flag changes on reload. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-26: fixed. The URL handler now uses an atomic - `URL_SCHEME_ENABLED` gate checked inside `handle_get_url_event` before - dispatching any parsed action. `AppRuntime::run` seeds that gate from launch - config before installing the handler and refreshes it on every - `ConfigReloaded` event. Disabling `external_control.url_scheme_enabled` via - live reload therefore causes subsequent `muninn://` events to be ignored - before they reach the runtime worker. The original reload-disable - counterexample is blocked. - -DEVANA-KEY: src/runtime_shell.rs:72-74,src/external_control/url_scheme.rs:51-72 | P2 | url-scheme-disable-ignored-reload -DEVANA-SUMMARY: Status=fixed | P2 high src/runtime_shell.rs:72-74 - Disabling url_scheme_enabled via live reload does not stop the installed muninn:// handler from dispatching control actions. diff --git a/.devana/20260625T083540Z-P2-openai-streaming-truncates-segments.md b/.devana/20260625T083540Z-P2-openai-streaming-truncates-segments.md deleted file mode 100644 index b0b2d80..0000000 --- a/.devana/20260625T083540Z-P2-openai-streaming-truncates-segments.md +++ /dev/null @@ -1,76 +0,0 @@ -DEVANA-FINDING: v1 -Priority: P2 | Confidence: medium | Security-sensitive: no | Status: fixed -Location: src/streaming_transcription/openai.rs:278-285 | Slug: openai-streaming-truncates-segments - -# OpenAI streaming finish exits after first completed segment - -## Finding - -`OpenAiStreamingSession::finish` breaks out of the websocket read loop as soon as any new `completed` transcription event arrives, without draining later completions. The transcript accumulator is built and tested to join multiple completed segments in arrival order, but `finish()` never consumes the remainder of the stream before calling `into_outcome()`. - -## Violated Invariant Or Contract - -Streaming finish should accumulate all final completed transcription segments before seeding `transcript.raw_text` for the recorded pipeline. - -## Oracle - -`finish()` break at `openai.rs:283-285` after first `completed_count` increase; `OpenAiTranscriptAccumulator::into_outcome` joins all entries in `self.completed` (`openai.rs:470-488`); test `parser_accumulates_completed_events_in_arrival_order` at `openai.rs:707-726` expects `"second first"` from two completions that `finish()` would not both receive if they arrive after the first post-commit event. - -## Counterexample - -1. Streaming mode with OpenAI Realtime; user finishes utterance; `input_audio_buffer.commit` is sent. -2. Server emits completed transcript `"first segment"` → loop breaks immediately. -3. Server later emits completed transcript `"second segment"` on the same socket → never read. -4. `into_outcome()` returns only `"first segment"`. -5. Recorded STT fallback is skipped because `has_non_empty_raw_text` is true with truncated text. - -## Why It Might Matter - -Seeded `transcript.raw_text` can be materially shorter than the spoken utterance, and downstream refine/injection operate on the truncated seed. - -## Proof - -Control-flow trace: commit → read loop → break on first completion increment → close socket → `into_outcome()` with partial `completed` vec. - -Counterevidence checked: deltas populate `partial_text` but `into_outcome()` ignores them. - -## Counterevidence Checked - -If the provider emits a single consolidated completion per utterance, behavior is correct. Multiple completions are explicitly supported by accumulator tests. Empty/whitespace first completion could yield no seed while a later completion holds the real text. - -## Suggested Next Step - -Drain the websocket until close or an explicit end signal after commit, then build the outcome from all completed events. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-26: still open, narrowed. `finish()` no longer breaks immediately - after the first completed event: it waits for the first completion, then drains - additional messages for `POST_COMPLETION_DRAIN_GRACE` (300 ms). That blocks - the back-to-back multi-completion counterexample covered by current regression - tests. A delayed second final completion after the grace still follows - `Err(_elapsed) => break`, closes the socket, and builds the outcome from only - the completions already read. No repo-local guard, provider contract, or test - proves every final segment arrives within that 300 ms grace, so the report - remains open with the delayed-segment counterexample. -- 2026-06-26: fixed. `finish()` no longer uses a fixed post-completion grace. - It waits for the server's `input_audio_buffer.committed` acknowledgement, - records that committed item id, then uses the committed item's - `conversation.item.done` content list to learn which audio content indexes - must produce `conversation.item.input_audio_transcription.completed` events. - The loop only exits once all finalized audio content indexes for that - committed item have completed (or the socket closes/errors, with the existing - controller finish timeout as the outer bound). This blocks both the delayed - final event counterexample and the review-discovered same-item delayed - multi-content counterexample. Final outcome assembly now prefers the - committed item's transcript parts and orders them by `content_index` so - unrelated item completions cannot contaminate the committed utterance. Added - `finish_waits_for_delayed_completion_for_committed_item` and - `finish_waits_for_all_finalized_audio_content_indexes`, plus coverage for - unrelated item completions and out-of-order committed audio parts; focused - OpenAI streaming tests, broader streaming tests, and the full library suite - pass. - -DEVANA-KEY: src/streaming_transcription/openai.rs:278-285 | P2 | openai-streaming-truncates-segments -DEVANA-SUMMARY: Status=fixed | P2 medium src/streaming_transcription/openai.rs:278-285 - OpenAI streaming finish now waits for transcription completion of the committed item instead of closing after a fixed post-completion grace. diff --git a/.devana/20260625T091238Z-P2-external-step-stdin-write-deadlock.md b/.devana/20260625T091238Z-P2-external-step-stdin-write-deadlock.md deleted file mode 100644 index 196c7e7..0000000 --- a/.devana/20260625T091238Z-P2-external-step-stdin-write-deadlock.md +++ /dev/null @@ -1,92 +0,0 @@ -DEVANA-FINDING: v1 -Priority: P2 | Confidence: high | Security-sensitive: no | Status: fixed -Location: src/runner/transport.rs:79-120 | Slug: external-step-stdin-write-deadlock - -# External pipeline step can hang unbounded; timeout does not cover the stdin write - -## Finding - -`run_command` writes the entire stdin payload to the child, awaits the write and -`shutdown` to completion, and only *afterwards* spawns the stdout/stderr drains -and wraps `child.wait()` in `timeout(timeout_budget, ...)`. The write phase at -lines 79-97 is not covered by any timeout and runs while nothing is draining the -child's stdout. A child that does not consume all of stdin (or that fills its own -stdout pipe before reading stdin) blocks the parent's `write_all` forever, and the -`timeout_budget` safety net at line 120 is never reached. - -## Violated Invariant Or Contract - -A configured pipeline step must complete or fail within `timeout_budget`. The -`timeout_budget` parameter exists precisely to bound step execution (see -`specs/11-runtime-resource-bounds`), but it only guards `child.wait()`, not the -`stdin.write_all`/`shutdown` that precede it. - -## Oracle - -Two source-of-truth signals: (1) the explicit `timeout(timeout_budget, child.wait())` -at line 120 establishes that step execution is intended to be time-bounded; the -write phase escaping it is an inconsistency. (2) Standard OS-pipe back-pressure: -a parent that writes more than the pipe buffer (~64 KB) to a child while not -concurrently draining the child's stdout will deadlock — the canonical reason -readers are normally spawned *before* the blocking write. - -## Counterexample - -Configure any external pipeline step (a `TextFilter` command, or one running in -`io_mode = envelope_json`) whose command either (a) does not read stdin to EOF, or -(b) starts writing to stdout as it reads. Feed it an envelope/text larger than the -OS pipe buffer (a long dictation in `envelope_json` mode serializes the transcript -plus trace, easily exceeding 64 KB). The child fills its stdout pipe and stops -reading stdin (case b), or never drains stdin (case a). The parent blocks at -`stdin.write_all(stdin_bytes)` (line 80). Because the stdout reader is not spawned -until line 115 and the timeout is not armed until line 120, the await never -returns and the step hangs indefinitely. - -## Why It Might Matter - -The dictation pipeline thread blocks permanently on a single misbehaving or -slow external step, so a recording never completes and the app appears frozen -with no error and no timeout firing. `kill_on_drop(true)` (line 61) does not help -because the child is never dropped during the hung write. - -## Proof - -Control-flow / ordering trace in `run_command`: -- line 80: `stdin.write_all(stdin_bytes).await` — blocking, no timeout, no concurrent stdout drain. -- line 90: `stdin.shutdown().await` — same. -- lines 115-118: stdout/stderr `read_to_end_capped` tasks spawned — only now is stdout drained. -- line 120: `timeout(timeout_budget, child.wait())` — the only time bound, reached only after the write already returned. - -## Counterevidence Checked - -- `kill_on_drop(true)` (line 61): does not fire during the hung write; the child is not dropped. -- `max_stdout_bytes` cap in `read_to_end_capped`: irrelevant while no reader is running. -- No `tokio::select!` or `timeout` wraps lines 79-97; confirmed by reading the full function. -- Reachability requires a user-configured external step; default-only pipelines without external commands are unaffected (hence P2, not P1). - -## Suggested Next Step - -Spawn the stdout/stderr drains before the stdin write, and wrap the write/shutdown -in the same `timeout_budget` (e.g. drive write and `child.wait()` under one -`tokio::select!`/`timeout`) so a stalled child cannot hang the pipeline. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `Status: ...` and the final `DEVANA-SUMMARY:` status. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Add dated notes below with the evidence checked. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-26: fixed. Reordered `run_command` to spawn the stdout/stderr drain - tasks before writing stdin, and moved the stdin `write_all`/`shutdown` together - with `child.wait()` into a single `timeout(timeout_budget, ...)` future (new - `WritePhaseError` enum maps the failing phase back to the right error kind). - This removes both deadlock paths: a child that emits output while reading is now - drained concurrently, and a child that never reads stdin to EOF is bounded by - `timeout_budget` instead of hanging. Added two regression tests in - `transport.rs`: `drains_stdout_while_writing_large_stdin_without_deadlock` - (512 KB round-trip through `cat`) and `times_out_when_child_never_reads_stdin` - (512 KB into `sleep 5` hits the 200 ms timeout). Both pass. - -DEVANA-KEY: src/runner/transport.rs:79-120 | P2 | external-step-stdin-write-deadlock -DEVANA-SUMMARY: Status=fixed | P2 high src/runner/transport.rs:79-120 - External step stdin write runs outside timeout_budget with no concurrent stdout drain, so a misbehaving step deadlocks the pipeline indefinitely. diff --git a/.devana/20260625T091239Z-P2-google-batch-multisegment-truncation.md b/.devana/20260625T091239Z-P2-google-batch-multisegment-truncation.md deleted file mode 100644 index c8151a4..0000000 --- a/.devana/20260625T091239Z-P2-google-batch-multisegment-truncation.md +++ /dev/null @@ -1,86 +0,0 @@ -DEVANA-FINDING: v1 -Priority: P2 | Confidence: high | Security-sensitive: no | Status: fixed -Location: src/stt_google_tool.rs:595-606 | Slug: google-batch-multisegment-truncation - -# Google batch adapter keeps only the first transcript segment, dropping the rest - -# Finding - -`extract_google_transcript_text` flattens `results[] -> alternatives[] -> -transcript` into a single iterator and takes `.next()`, returning exactly one -string. Google's `speech:recognize` returns one `results` entry per consecutive -audio segment for longer audio, with the full transcript being the concatenation -of `results[i].alternatives[0].transcript`. Taking `.next()` keeps only the first -segment's first alternative and silently discards every later segment. - -## Violated Invariant Or Contract - -The shared STT contract is "store the full transcript in `transcript.raw_text`." -The sibling adapters honor this by aggregating all segments: Whisper concatenates -all segments (`stt_whisper_cpp_tool.rs` `collect_transcript_text`), Deepgram joins -all final segments (`stt_deepgram_tool.rs`). The design note -`specs/12-google-live-stt/design.md` references `results[].alternatives[].transcript` -with a plural `results[]`, implying all results must be read. - -## Oracle - -Differential against the neighboring adapter implementations (Whisper/Deepgram -both aggregate over all segments) and against Google's documented response shape -(multiple `results`, one per consecutive segment). A correct implementation joins -all segment transcripts; this one returns only the first via `.next()`. - -## Counterexample - -Response body: -`{"results":[{"alternatives":[{"transcript":"hello world"}]},{"alternatives":[{"transcript":" goodbye now"}]}]}` - -The iterator `results -> flatten -> alternatives -> flatten -> transcript -> -.next()` yields `"hello world"` and discards `" goodbye now"`. `raw_text` becomes -`"hello world"` instead of `"hello world goodbye now"`. - -## Why It Might Matter - -For any utterance long enough that Google splits the response into more than one -`results` entry, the persisted/injected transcript is truncated to roughly the -first segment with no error surfaced. This is silent data loss on the product's -core function (transcription accuracy) and grows worse with longer dictation. - -## Proof - -Read of `src/stt_google_tool.rs:595-606`. The chained `.flatten()` collapses all -`results` entries and all their `alternatives` into one stream; `.next()` returns -a single `&str`. There is no later concatenation. The guard at line 608 only -controls empty-vs-error classification and does not aggregate. - -## Counterevidence Checked - -- Existing tests feed only single-`results` bodies (around lines 954-967, 1003), so multi-segment truncation is untested, not prevented. -- `.next()` is unambiguously single-element; no surrounding join collects the rest. -- The known excluded finding covers OpenAI *streaming* truncation (`streaming_transcription/openai.rs:278-285`); this is the separate Google *batch* adapter. -- Short single-segment utterances yield one `results` entry and are unaffected, which is why this surfaces as a length-dependent truncation (P2) rather than a total failure. - -## Suggested Next Step - -Replace `.next()` with an aggregation over all `results` entries, joining each -`results[i].alternatives[0].transcript` (matching the Whisper/Deepgram behavior), -then trim the joined string. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `Status: ...` and the final `DEVANA-SUMMARY:` status. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Add dated notes below with the evidence checked. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-26: fixed. Rewrote `extract_google_transcript_text` to aggregate over - all `results` entries, taking each result's first alternative - (`alternatives[0].transcript`) and concatenating them, then trimming the joined - string — matching the Whisper/Deepgram aggregation behavior. Segments carry their - own leading spaces, so concatenation without a separator reproduces Google's - natural spacing. Added regression tests - `response_json_concatenates_multiple_result_segments` (two segments → - "hello world goodbye now") and `response_json_uses_first_alternative_per_result`. - Both pass alongside the existing single-segment/empty/missing-results tests. - -DEVANA-KEY: src/stt_google_tool.rs:595-606 | P2 | google-batch-multisegment-truncation -DEVANA-SUMMARY: Status=fixed | P2 high src/stt_google_tool.rs:595-606 - Google batch adapter takes .next() over flattened results, dropping every transcript segment after the first and truncating longer dictations. diff --git a/.devana/20260625T091240Z-P3-streaming-error-close-as-empty.md b/.devana/20260625T091240Z-P3-streaming-error-close-as-empty.md deleted file mode 100644 index cecc4ff..0000000 --- a/.devana/20260625T091240Z-P3-streaming-error-close-as-empty.md +++ /dev/null @@ -1,89 +0,0 @@ -DEVANA-FINDING: v1 -Priority: P3 | Confidence: medium | Security-sensitive: no | Status: fixed -Location: src/streaming_transcription/deepgram.rs:233, src/streaming_transcription/openai.rs:291 | Slug: streaming-error-close-as-empty - -# Server error Close frame is reported as an empty transcript instead of a failure - -## Finding - -Both websocket streaming backends finalize with `Message::Close(_) => break`, -discarding the close frame's code and reason, then return -`self.transcript.into_outcome()`. When the stream ended without any final text, -`into_outcome` builds an empty (success) outcome. A server-initiated *error* close -(auth rejected, quota exceeded, internal/policy error) is therefore classified as -an empty transcript rather than a provider failure. - -## Violated Invariant Or Contract - -A connection torn down by the server with an error close code is a failure and -should map to `StreamingTranscriptionError::failed(...)` (the trait's `finish` -returns `Result`), not to -an `Ok` empty outcome carrying an `EmptyTranscript` diagnostic. - -## Oracle - -Same-file contract mismatch: the identical failure delivered as a JSON text -message routes through `handle_text_message` and yields `Err` — -`Some("Error") => Err(deepgram_provider_error(&value))` at -`src/streaming_transcription/deepgram.rs:340` (OpenAI mirrors this at openai.rs:402). -A failure conveyed via a Close frame should be classified equivalently, but the -`Message::Close(_)` arm binds nothing and breaks into the success path. - -## Counterexample - -The provider rejects the stream with a close frame (e.g. Deepgram code 1008/4001, -reason "insufficient_credits") rather than an `{"type":"Error"}` text message. The -`Close(_)` arm discards `frame.code`/`frame.reason`, breaks, and `into_outcome()` -returns an empty success outcome. The controller -(`src/streaming_transcription.rs`) sees `Ok` with no final text and surfaces -`EmptyTranscript`, masking the real cause and falling back to recorded -transcription with a misleading diagnostic. - -## Why It Might Matter - -A real provider error (auth/quota/server fault) is reported to the user as "no -speech detected" instead of an error, hiding actionable failures (e.g. an expired -API key) behind an empty-transcript message. Impact is diagnostic correctness, not -data loss, since recorded-transcription fallback still runs — hence P3. - -## Proof - -Contract mismatch across two arms of the same match: -- `deepgram.rs:340` / `openai.rs:402`: `"Error"` text message -> `Err(... failed ...)`. -- `deepgram.rs:233` / `openai.rs:291`: `Message::Close(_) => break` -> `Ok(into_outcome())`, which builds an empty outcome when no final text was seen. -The close frame (and its code/reason) is available at the break point and is deliberately discarded. - -## Counterevidence Checked - -- Normal/benign closes also arrive as a Close frame, so breaking is correct for them; the misclassification only bites on error closes (medium confidence on reachability — providers often send a text `Error` first). -- Google streaming is RPC-based and unaffected. -- `into_outcome` was confirmed to never produce a `Failed` variant; it only builds empty/produced outcomes, so the close path cannot currently surface the error. - -## Suggested Next Step - -Inspect the close frame: when the code/reason indicates an error (non-1000, or a -provider error range), return `StreamingTranscriptionError::failed(...)` instead of -breaking into the empty-success path. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `Status: ...` and the final `DEVANA-SUMMARY:` status. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Add dated notes below with the evidence checked. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-26: fixed. Both backends now inspect the close frame in the - `Message::Close` arm. A frame with a non-benign code (anything other than 1000 - Normal / 1001 Going Away) is captured as a - `StreamingTranscriptionError::failed(...)` carrying the close code and reason - (codes `deepgram_closed_with_error` / `openai_realtime_closed_with_error`); - a close without a frame is treated as benign. - After the loop, the error is returned only when no usable final text was - produced, so a transcript completed before a trailing error close is still - returned as success. Added regression tests: deepgram - `finish_reports_error_close_frame_as_failure` and - `finish_keeps_transcript_when_error_close_follows_final_text`, and openai - `finish_reports_error_close_frame_as_failure`. All streaming tests pass (52). - -DEVANA-KEY: src/streaming_transcription/deepgram.rs:233,src/streaming_transcription/openai.rs:291 | P3 | streaming-error-close-as-empty -DEVANA-SUMMARY: Status=fixed | P3 medium src/streaming_transcription/deepgram.rs:233 - Websocket error Close frames are swallowed into an empty-success outcome, masking provider auth/quota failures as empty transcripts. From e5d47bcb585f3484392f8cfc44a5bb90ffcfaa51 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sun, 28 Jun 2026 15:37:19 +0100 Subject: [PATCH 16/16] fix: address PR review feedback --- src/config/logging.rs | 2 +- src/config_watch.rs | 2 +- src/envelope.rs | 2 +- src/error.rs | 15 ++- src/external_control/mcp.rs | 62 ++++++++--- src/lib.rs | 18 +--- src/logging.rs | 9 +- src/mock.rs | 12 +++ src/orchestrator.rs | 5 +- src/platform.rs | 2 +- src/replay_dispatch.rs | 206 +++++++++++++++++++++++++++++++++++- src/runner.rs | 2 +- src/runner/transport.rs | 176 +++++++++++++++++++++++++----- src/runtime_permissions.rs | 10 +- src/runtime_shell.rs | 17 ++- src/secrets.rs | 2 +- src/state.rs | 2 +- src/stt_google_tool.rs | 9 +- src/target_context.rs | 2 +- 19 files changed, 465 insertions(+), 90 deletions(-) diff --git a/src/config/logging.rs b/src/config/logging.rs index dbfcba9..a29dcf7 100644 --- a/src/config/logging.rs +++ b/src/config/logging.rs @@ -38,4 +38,4 @@ impl Default for LoggingConfig { replay_max_bytes: 52_428_800, } } -} \ No newline at end of file +} diff --git a/src/config_watch.rs b/src/config_watch.rs index b847c94..20aaa2c 100644 --- a/src/config_watch.rs +++ b/src/config_watch.rs @@ -240,4 +240,4 @@ mod tests { min ); } -} \ No newline at end of file +} diff --git a/src/envelope.rs b/src/envelope.rs index 5016c93..2865a3e 100644 --- a/src/envelope.rs +++ b/src/envelope.rs @@ -206,4 +206,4 @@ mod tests { assert!(decoded.output.final_text.is_none()); assert!(decoded.errors.is_empty()); } -} \ No newline at end of file +} diff --git a/src/error.rs b/src/error.rs index 3430393..fa0444a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -41,6 +41,14 @@ 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 { @@ -48,7 +56,10 @@ pub enum MacosAdapterError { #[error("unsupported platform")] UnsupportedPlatform, /// One or more required TCC permissions are not granted. - #[error("missing required permissions: {permissions:?}")] + #[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")] @@ -79,4 +90,4 @@ impl MacosAdapterError { message: message.into(), } } -} \ No newline at end of file +} diff --git a/src/external_control/mcp.rs b/src/external_control/mcp.rs index 86ebf0f..b188f32 100644 --- a/src/external_control/mcp.rs +++ b/src/external_control/mcp.rs @@ -5,6 +5,10 @@ //! [`RuntimeStatusHandle::snapshot`]. use std::net::SocketAddr; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; use rmcp::{ model::{CallToolResult, Content, ServerCapabilities, ServerInfo}, @@ -30,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] @@ -38,7 +42,7 @@ impl RecordingControlServer { fn new( proxy: EventLoopProxy, status: RuntimeStatusHandle, - start_recording_enabled: bool, + start_recording_enabled: Arc, ) -> Self { Self { proxy, @@ -95,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( @@ -178,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() @@ -218,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, @@ -237,7 +235,7 @@ async fn serve( Ok(RecordingControlServer::new( proxy.clone(), status.clone(), - start_recording_enabled, + start_recording_enabled.clone(), )) }, LocalSessionManager::default().into(), @@ -267,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() { @@ -283,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/lib.rs b/src/lib.rs index d255e98..88439c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -412,22 +412,13 @@ pub trait IndicatorAdapter: Send + Sync { self.set_state(state).await } /// Show `state` for at least `min_duration`, then revert to `fallback_state`. - /// - /// Default implementation ignores timing and delegates to [`IndicatorAdapter::set_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. - /// - /// Default implementation ignores glyphs and delegates to - /// [`IndicatorAdapter::set_temporary_state`]. async fn set_temporary_state_with_glyph( &mut self, state: IndicatorState, @@ -435,12 +426,7 @@ 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. diff --git a/src/logging.rs b/src/logging.rs index 4f513d8..0a13d20 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -49,9 +49,7 @@ pub(crate) enum DiagnosticEvent { path: Option, }, /// Config file change detected on disk. - ConfigChanged { - path: String, - }, + ConfigChanged { path: String }, /// Recording capture began after permissions passed. RecordingStarted { profile_id: String, @@ -63,10 +61,7 @@ pub(crate) enum DiagnosticEvent { source: &'static str, }, /// Runtime worker failed to build or exited with an error. - RuntimeWorkerFailed { - stage: &'static str, - detail: String, - }, + RuntimeWorkerFailed { stage: &'static str, detail: String }, } #[cfg(test)] diff --git a/src/mock.rs b/src/mock.rs index dd74379..7cc6698 100644 --- a/src/mock.rs +++ b/src/mock.rs @@ -139,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() { diff --git a/src/orchestrator.rs b/src/orchestrator.rs index d02ec92..6f28390 100644 --- a/src/orchestrator.rs +++ b/src/orchestrator.rs @@ -192,7 +192,10 @@ mod tests { route.target, InjectionTarget::TranscriptRawText("ship to San Francisco".to_string()) ); - assert_eq!(route.reason, InjectionRouteReason::SelectedTranscriptRawText); + assert_eq!( + route.reason, + InjectionRouteReason::SelectedTranscriptRawText + ); } #[test] diff --git a/src/platform.rs b/src/platform.rs index b120904..58045b4 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -103,4 +103,4 @@ mod tests { PermissionPreflightStatus::unsupported() ); } -} \ No newline at end of file +} diff --git a/src/replay_dispatch.rs b/src/replay_dispatch.rs index 93e49f6..bf4c1d3 100644 --- a/src/replay_dispatch.rs +++ b/src/replay_dispatch.rs @@ -53,9 +53,9 @@ impl ReplayPersistenceService { /// Queue replay persistence for one utterance. /// - /// Returns `true` when the worker accepted the request and will delete the - /// temporary WAV after writing. Returns `false` when replay is disabled, the - /// queue is full, or the worker is unavailable. + /// 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, @@ -64,7 +64,9 @@ impl ReplayPersistenceService { route: InjectionRoute, recorded: RecordedAudio, ) -> bool { - let Some(resolved) = resolved else { + let Some(resolved) = + resolved.filter(|resolved| resolved.effective_config.logging.replay_enabled) + else { return false; }; let Some(sender) = self.sender.as_ref() else { @@ -178,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 fa6d27b..2d048b4 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -334,7 +334,7 @@ pub struct PipelineTraceEntry { pub duration_ms: u64, /// True when the step hit its per-step timeout budget. pub timed_out: bool, - /// Child process exit code when the step ran externally; `None` on timeout. + /// 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, diff --git a/src/runner/transport.rs b/src/runner/transport.rs index 67bb871..a8898ba 100644 --- a/src/runner/transport.rs +++ b/src/runner/transport.rs @@ -11,7 +11,10 @@ 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)] @@ -143,8 +146,13 @@ pub(super) async fn run_command( let status = match wait_result { Ok(Ok(status)) => status, Ok(Err(WritePhaseError::Write(source))) => { - 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::WriteStdin, details: format_command_error_details(source.to_string(), cleanup_notes), @@ -154,8 +162,13 @@ pub(super) async fn run_command( }); } Ok(Err(WritePhaseError::Close(source))) => { - 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::CloseStdin, details: format_command_error_details(source.to_string(), cleanup_notes), @@ -165,8 +178,13 @@ pub(super) async fn run_command( }); } Ok(Err(WritePhaseError::Wait(source))) => { - 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::Wait, details: format_command_error_details(source.to_string(), cleanup_notes), @@ -176,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, @@ -262,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 { @@ -344,4 +438,28 @@ mod tests { 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_permissions.rs b/src/runtime_permissions.rs index 65fca52..70b572f 100644 --- a/src/runtime_permissions.rs +++ b/src/runtime_permissions.rs @@ -1,8 +1,9 @@ //! macOS TCC permission refresh before recording and injection. //! //! Hotkey recording requires Input Monitoring; tray and external controls use a -//! lighter preflight. Prompting during a user gesture may grant access but still -//! aborts that gesture so the user retries with stable permissions. +//! 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}; @@ -150,8 +151,9 @@ 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. Hotkey -/// Input Monitoring prompts always abort; tray starts continue after IM prompts. +/// 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, diff --git a/src/runtime_shell.rs b/src/runtime_shell.rs index ed6df36..ca1e470 100644 --- a/src/runtime_shell.rs +++ b/src/runtime_shell.rs @@ -5,7 +5,13 @@ //! 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; +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; use anyhow::{Context, Result}; use muninn::{ @@ -117,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; @@ -157,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(), ); } } @@ -235,6 +244,10 @@ 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, diff --git a/src/secrets.rs b/src/secrets.rs index 9722678..e8ab77b 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -109,4 +109,4 @@ mod tests { assert_eq!(resolved.as_deref(), Some("config-fallback")); } -} \ No newline at end of file +} diff --git a/src/state.rs b/src/state.rs index c9c1c18..0292194 100644 --- a/src/state.rs +++ b/src/state.rs @@ -156,4 +156,4 @@ mod tests { AppState::Injecting ); } -} \ No newline at end of file +} diff --git a/src/stt_google_tool.rs b/src/stt_google_tool.rs index 6d748cd..0f12c2f 100644 --- a/src/stt_google_tool.rs +++ b/src/stt_google_tool.rs @@ -619,7 +619,10 @@ fn extract_google_transcript_text(body: &[u8]) -> Result { .and_then(|alternative| alternative.get("transcript")) .and_then(Value::as_str) }) - .collect::(); + .map(str::trim) + .filter(|segment| !segment.is_empty()) + .collect::>() + .join(" "); let transcript = transcript.trim().to_string(); if !transcript.is_empty() || value.get("results").is_some() { @@ -977,9 +980,9 @@ mod tests { } #[test] - fn response_json_concatenates_multiple_result_segments() { + fn response_json_joins_multiple_result_segments() { let transcript = extract_google_transcript_text( - br#"{"results":[{"alternatives":[{"transcript":"hello world"}]},{"alternatives":[{"transcript":" goodbye now"}]}]}"#, + br#"{"results":[{"alternatives":[{"transcript":"hello world"}]},{"alternatives":[{"transcript":"goodbye now"}]}]}"#, ) .expect("extract text from multi-segment json"); assert_eq!(transcript, "hello world goodbye now"); diff --git a/src/target_context.rs b/src/target_context.rs index 0d09390..0e53437 100644 --- a/src/target_context.rs +++ b/src/target_context.rs @@ -189,4 +189,4 @@ fn normalize_optional_string(value: Option) -> Option { fn normalize_string(value: String) -> Option { normalize_optional_string(Some(value)) -} \ No newline at end of file +}