Land release blocker fixes and workspace cleanup
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

This commit is contained in:
2026-04-23 00:16:09 +01:00
parent d7363cc913
commit 9b0067b4c0
36 changed files with 1529 additions and 418 deletions

View File

@@ -225,13 +225,19 @@ mod tests {
assert_eq!(LlmPromptPreset::parse("EMAIL"), LlmPromptPreset::Email); assert_eq!(LlmPromptPreset::parse("EMAIL"), LlmPromptPreset::Email);
assert_eq!(LlmPromptPreset::parse("notes"), LlmPromptPreset::Notes); assert_eq!(LlmPromptPreset::parse("notes"), LlmPromptPreset::Notes);
assert_eq!(LlmPromptPreset::parse("meeting"), LlmPromptPreset::Notes); assert_eq!(LlmPromptPreset::parse("meeting"), LlmPromptPreset::Notes);
assert_eq!(LlmPromptPreset::parse("meeting-notes"), LlmPromptPreset::Notes); assert_eq!(
LlmPromptPreset::parse("meeting-notes"),
LlmPromptPreset::Notes
);
assert_eq!(LlmPromptPreset::parse("code"), LlmPromptPreset::Code); assert_eq!(LlmPromptPreset::parse("code"), LlmPromptPreset::Code);
assert_eq!(LlmPromptPreset::parse("software"), LlmPromptPreset::Code); assert_eq!(LlmPromptPreset::parse("software"), LlmPromptPreset::Code);
// Unknown values and explicit default fall back safely. // Unknown values and explicit default fall back safely.
assert_eq!(LlmPromptPreset::parse("default"), LlmPromptPreset::Default); assert_eq!(LlmPromptPreset::parse("default"), LlmPromptPreset::Default);
assert_eq!(LlmPromptPreset::parse(""), LlmPromptPreset::Default); assert_eq!(LlmPromptPreset::parse(""), LlmPromptPreset::Default);
assert_eq!(LlmPromptPreset::parse("random-unknown"), LlmPromptPreset::Default); assert_eq!(
LlmPromptPreset::parse("random-unknown"),
LlmPromptPreset::Default
);
} }
#[test] #[test]
@@ -240,7 +246,10 @@ mod tests {
// be empty so it composes cleanly with dictionary suffix. // be empty so it composes cleanly with dictionary suffix.
assert!(LlmPromptPreset::Default.suffix().is_empty()); assert!(LlmPromptPreset::Default.suffix().is_empty());
assert!(LlmPromptPreset::Email.suffix().contains("email")); assert!(LlmPromptPreset::Email.suffix().contains("email"));
assert!(LlmPromptPreset::Notes.suffix().to_lowercase().contains("bullet")); assert!(LlmPromptPreset::Notes
.suffix()
.to_lowercase()
.contains("bullet"));
assert!(LlmPromptPreset::Code.suffix().contains("technical")); assert!(LlmPromptPreset::Code.suffix().contains("technical"));
} }
} }

View File

@@ -540,8 +540,12 @@ mod tests {
fn is_hallucination_allows_dialogue_containing_thanks_mid_sentence() { fn is_hallucination_allows_dialogue_containing_thanks_mid_sentence() {
// Exact-match on trail phrases means legitimate dialogue that // Exact-match on trail phrases means legitimate dialogue that
// mentions "thanks" or "subscribe" is never dropped. // mentions "thanks" or "subscribe" is never dropped.
assert!(!is_hallucination("Thanks for the heads up on the migration")); assert!(!is_hallucination(
assert!(!is_hallucination("Please subscribe to the RSS feed and tell me when it updates")); "Thanks for the heads up on the migration"
));
assert!(!is_hallucination(
"Please subscribe to the RSS feed and tell me when it updates"
));
} }
#[test] #[test]

View File

@@ -168,7 +168,10 @@ mod tests {
]; ];
for (input, expected) in cases { for (input, expected) in cases {
let out = to_plain_text(&[seg(input)]); let out = to_plain_text(&[seg(input)]);
assert_eq!(out, expected, "input {input:?} should strip to {expected:?}"); assert_eq!(
out, expected,
"input {input:?} should strip to {expected:?}"
);
} }
} }

View File

@@ -42,9 +42,8 @@ impl WavWriter {
}; };
let file = std::fs::File::create(path).map_err(KonError::Io)?; let file = std::fs::File::create(path).map_err(KonError::Io)?;
let buffered = BufWriter::new(file); let buffered = BufWriter::new(file);
let inner = hound::WavWriter::new(buffered, spec).map_err(|e| { let inner = hound::WavWriter::new(buffered, spec)
KonError::Io(std::io::Error::other(format!("WAV create failed: {e}"))) .map_err(|e| KonError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?;
})?;
Ok(Self { Ok(Self {
inner, inner,
samples_since_flush: 0, samples_since_flush: 0,
@@ -77,9 +76,9 @@ impl WavWriter {
/// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural /// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural
/// boundaries (end-of-utterance, UI events) for tighter recovery. /// boundaries (end-of-utterance, UI events) for tighter recovery.
pub fn flush(&mut self) -> Result<()> { pub fn flush(&mut self) -> Result<()> {
self.inner.flush().map_err(|e| { self.inner
KonError::Io(std::io::Error::other(format!("WAV flush failed: {e}"))) .flush()
})?; .map_err(|e| KonError::Io(std::io::Error::other(format!("WAV flush failed: {e}"))))?;
self.samples_since_flush = 0; self.samples_since_flush = 0;
Ok(()) Ok(())
} }
@@ -244,8 +243,7 @@ mod tests {
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
// Write 100 samples (200 bytes at 16-bit). // Write 100 samples (200 bytes at 16-bit).
let original = let original = AudioSamples::mono_16khz((0..100).map(|i| (i as f32) / 100.0).collect());
AudioSamples::mono_16khz((0..100).map(|i| (i as f32) / 100.0).collect());
write_wav(&path, &original).unwrap(); write_wav(&path, &original).unwrap();
// Drop the last 10 bytes — 5 samples' worth. hound's iterator // Drop the last 10 bytes — 5 samples' worth. hound's iterator

View File

@@ -1,29 +1,77 @@
/// Store an API key in the OS keychain. use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
/// Store an API key in Kon's process-local keystore.
/// ///
/// Stub implementation using environment variables until the `keyring` crate is /// Keys are held in memory for the lifetime of the process and are lost on
/// added. Keys are only held in-process and lost on exit. /// exit. This avoids the undefined behaviour of mutating process environment
/// variables from arbitrary threads while keeping the public API safe.
/// ///
/// # Safety note /// `retrieve_api_key` still falls back to `KON_API_KEY_<PROVIDER>` environment
/// `std::env::set_var` is deprecated in Rust 2024 edition and is **not** /// variables so externally injected secrets continue to work.
/// thread-safe — mutating the environment while other threads read it is
/// undefined behaviour. This is acceptable during single-threaded app init
/// but must not be called from async/multi-threaded contexts.
/// ///
/// TODO: Replace with the `keyring` crate (or platform-native credential /// TODO: Replace with the `keyring` crate (or platform-native credential
/// storage) so keys persist across sessions and are accessed safely. /// storage) so keys persist across sessions and are accessed safely.
#[allow(deprecated)] // set_var deprecated in Rust 2024 edition
pub fn store_api_key(provider: &str, key: &str) { pub fn store_api_key(provider: &str, key: &str) {
// SAFETY: Only safe when called from a single-threaded context (e.g. app api_key_store()
// initialisation). See doc comment above. .lock()
std::env::set_var(format!("KON_API_KEY_{}", provider.to_uppercase()), key); .unwrap()
.insert(provider_env_key(provider), key.to_string());
} }
/// Retrieve an API key from the OS keychain. /// Retrieve an API key from Kon's process-local keystore.
/// ///
/// Stub implementation using environment variables until the `keyring` crate is /// Returns a previously stored in-memory key when present, otherwise falls
/// added. Returns `None` if no key has been stored this session. /// back to the read-only `KON_API_KEY_<PROVIDER>` environment variable so
/// /// operator-supplied secrets still work.
/// TODO: Replace with the `keyring` crate alongside `store_api_key`.
pub fn retrieve_api_key(provider: &str) -> Option<String> { pub fn retrieve_api_key(provider: &str) -> Option<String> {
std::env::var(format!("KON_API_KEY_{}", provider.to_uppercase())).ok() let env_key = provider_env_key(provider);
api_key_store()
.lock()
.unwrap()
.get(&env_key)
.cloned()
.or_else(|| std::env::var(env_key).ok())
}
fn api_key_store() -> &'static Mutex<HashMap<String, String>> {
static STORE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
STORE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn provider_env_key(provider: &str) -> String {
format!("KON_API_KEY_{}", provider.to_uppercase())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn unique_provider(prefix: &str) -> String {
static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
format!("{prefix}_{}", NEXT_ID.fetch_add(1, Ordering::Relaxed))
}
#[test]
fn stored_key_is_retrievable_without_env_mutation() {
let provider = unique_provider("provider");
store_api_key(&provider, "secret-token");
assert_eq!(
retrieve_api_key(&provider),
Some("secret-token".to_string())
);
}
#[test]
fn providers_do_not_overlap() {
let first = unique_provider("first");
let second = unique_provider("second");
store_api_key(&first, "alpha");
store_api_key(&second, "beta");
assert_eq!(retrieve_api_key(&first), Some("alpha".to_string()));
assert_eq!(retrieve_api_key(&second), Some("beta".to_string()));
}
} }

View File

@@ -349,10 +349,7 @@ fn is_event_device(path: &Path) -> bool {
/// Return true when the device's reported key set includes the combo's /// Return true when the device's reported key set includes the combo's
/// configured trigger key. A device that reports no keys at all (for /// configured trigger key. A device that reports no keys at all (for
/// example a mouse whose `EV_KEY` capability is buttons only) is rejected. /// example a mouse whose `EV_KEY` capability is buttons only) is rejected.
fn device_supports_combo( fn device_supports_combo(supported: Option<&AttributeSetRef<Key>>, combo: &HotkeyCombo) -> bool {
supported: Option<&AttributeSetRef<Key>>,
combo: &HotkeyCombo,
) -> bool {
supported.map_or(false, |keys| keys.contains(Key::new(combo.key_code))) supported.map_or(false, |keys| keys.contains(Key::new(combo.key_code)))
} }

View File

@@ -18,6 +18,8 @@ pub mod prompts;
pub use model_manager::{recommend_tier, LlmModelId, LlmModelInfo}; pub use model_manager::{recommend_tier, LlmModelId, LlmModelInfo};
const DEFAULT_CONTEXT_TOKENS: u32 = 4096; const DEFAULT_CONTEXT_TOKENS: u32 = 4096;
const MAX_CONTEXT_TOKENS: u32 = 8192;
const CONTEXT_RESERVE_TOKENS: u32 = 64;
const GENERATION_SEED: u32 = 0; const GENERATION_SEED: u32 = 0;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -26,6 +28,15 @@ pub enum EngineError {
NotLoaded, NotLoaded,
#[error("LLM load failed: {0}")] #[error("LLM load failed: {0}")]
LoadFailed(String), LoadFailed(String),
#[error(
"prompt too long: {prompt_tokens} prompt tokens exceed the {available_prompt_tokens}-token prompt budget for an {context_window}-token context with {max_tokens} reserved response tokens"
)]
PromptTooLong {
prompt_tokens: usize,
max_tokens: u32,
available_prompt_tokens: u32,
context_window: u32,
},
#[error("inference failed: {0}")] #[error("inference failed: {0}")]
Inference(String), Inference(String),
#[error("model output not valid JSON: {0}")] #[error("model output not valid JSON: {0}")]
@@ -149,7 +160,7 @@ impl LlmEngine {
return Ok(String::new()); return Ok(String::new());
} }
let n_ctx = context_window_size(prompt_tokens.len(), config.max_tokens); let n_ctx = preflight_context_window(prompt_tokens.len(), config.max_tokens)?;
let thread_count = i32::try_from(num_cpus::get().max(1)).unwrap_or(4); let thread_count = i32::try_from(num_cpus::get().max(1)).unwrap_or(4);
let ctx_params = LlamaContextParams::default() let ctx_params = LlamaContextParams::default()
.with_n_ctx(Some( .with_n_ctx(Some(
@@ -317,8 +328,26 @@ impl LlmEngine {
fn context_window_size(prompt_tokens: usize, max_tokens: u32) -> u32 { fn context_window_size(prompt_tokens: usize, max_tokens: u32) -> u32 {
let required = prompt_tokens let required = prompt_tokens
.saturating_add(max_tokens as usize) .saturating_add(max_tokens as usize)
.saturating_add(64); .saturating_add(CONTEXT_RESERVE_TOKENS as usize);
DEFAULT_CONTEXT_TOKENS.max(required.min(8192) as u32) DEFAULT_CONTEXT_TOKENS.max(required.min(MAX_CONTEXT_TOKENS as usize) as u32)
}
fn preflight_context_window(prompt_tokens: usize, max_tokens: u32) -> Result<u32, EngineError> {
let required = prompt_tokens
.saturating_add(max_tokens as usize)
.saturating_add(CONTEXT_RESERVE_TOKENS as usize);
if required > MAX_CONTEXT_TOKENS as usize {
let available_prompt_tokens =
MAX_CONTEXT_TOKENS.saturating_sub(max_tokens.saturating_add(CONTEXT_RESERVE_TOKENS));
return Err(EngineError::PromptTooLong {
prompt_tokens,
max_tokens,
available_prompt_tokens,
context_window: MAX_CONTEXT_TOKENS,
});
}
Ok(context_window_size(prompt_tokens, max_tokens))
} }
fn first_stop_index(text: &str, stop_sequences: &[String]) -> Option<usize> { fn first_stop_index(text: &str, stop_sequences: &[String]) -> Option<usize> {
@@ -417,4 +446,24 @@ mod tests {
let index = first_stop_index(text, &["<|im_end|>".into(), "zzz".into()]); let index = first_stop_index(text, &["<|im_end|>".into(), "zzz".into()]);
assert_eq!(index, Some(5)); assert_eq!(index, Some(5));
} }
#[test]
fn prompt_preflight_rejects_oversized_prompt_tokens() {
let err = preflight_context_window(7_105, 1_024).unwrap_err();
assert!(matches!(
err,
EngineError::PromptTooLong {
prompt_tokens: 7_105,
max_tokens: 1_024,
available_prompt_tokens: 7_104,
context_window: MAX_CONTEXT_TOKENS,
}
));
}
#[test]
fn prompt_preflight_keeps_prompts_within_budget() {
let n_ctx = preflight_context_window(7_104, 1_024).unwrap();
assert_eq!(n_ctx, MAX_CONTEXT_TOKENS);
}
} }

View File

@@ -226,7 +226,9 @@ async fn list_transcripts_tool(pool: &SqlitePool, args: Value) -> Result<Value,
}) })
.collect(); .collect();
Ok(text_content(serde_json::to_string_pretty(&summaries).unwrap())) Ok(text_content(
serde_json::to_string_pretty(&summaries).unwrap(),
))
} }
async fn get_transcript_tool(pool: &SqlitePool, args: Value) -> Result<Value, JsonRpcError> { async fn get_transcript_tool(pool: &SqlitePool, args: Value) -> Result<Value, JsonRpcError> {
@@ -288,7 +290,9 @@ async fn search_transcripts_tool(pool: &SqlitePool, args: Value) -> Result<Value
}) })
.collect(); .collect();
Ok(text_content(serde_json::to_string_pretty(&summaries).unwrap())) Ok(text_content(
serde_json::to_string_pretty(&summaries).unwrap(),
))
} }
async fn list_tasks_tool(pool: &SqlitePool) -> Result<Value, JsonRpcError> { async fn list_tasks_tool(pool: &SqlitePool) -> Result<Value, JsonRpcError> {
@@ -311,7 +315,9 @@ async fn list_tasks_tool(pool: &SqlitePool) -> Result<Value, JsonRpcError> {
}) })
.collect(); .collect();
Ok(text_content(serde_json::to_string_pretty(&summaries).unwrap())) Ok(text_content(
serde_json::to_string_pretty(&summaries).unwrap(),
))
} }
fn text_content(text: String) -> Value { fn text_content(text: String) -> Value {
@@ -404,7 +410,10 @@ mod tests {
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
let response = handle_message(&pool, request).await.expect("has response"); let response = handle_message(&pool, request).await.expect("has response");
let tools = response.result.expect("ok")["tools"].as_array().unwrap().clone(); let tools = response.result.expect("ok")["tools"]
.as_array()
.unwrap()
.clone();
let names: Vec<String> = tools let names: Vec<String> = tools
.iter() .iter()
.map(|tool| tool["name"].as_str().unwrap().to_string()) .map(|tool| tool["name"].as_str().unwrap().to_string())
@@ -426,7 +435,9 @@ mod tests {
assert_eq!(resp.jsonrpc, "2.0"); assert_eq!(resp.jsonrpc, "2.0");
assert_eq!(resp.id, Value::Null); assert_eq!(resp.id, Value::Null);
assert!(resp.result.is_none()); assert!(resp.result.is_none());
let err = resp.error.expect("parse_error_response must carry an error"); let err = resp
.error
.expect("parse_error_response must carry an error");
assert_eq!(err.code, -32700); assert_eq!(err.code, -32700);
assert!(err.message.contains("Parse error")); assert!(err.message.contains("Parse error"));
assert!(err.message.contains("expected value")); assert!(err.message.contains("expected value"));
@@ -449,7 +460,9 @@ mod tests {
}); });
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
kon_storage::migrations::run_migrations(&pool).await.unwrap(); kon_storage::migrations::run_migrations(&pool)
.await
.unwrap();
let response = handle_message(&pool, request).await.expect("has response"); let response = handle_message(&pool, request).await.expect("has response");
assert!( assert!(

View File

@@ -7,10 +7,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let db_path = kon_storage::database_path(); let db_path = kon_storage::database_path();
eprintln!( eprintln!("[kon-mcp] opening Kon database at {}", db_path.display());
"[kon-mcp] opening Kon database at {}",
db_path.display()
);
let pool = kon_storage::init(&db_path).await?; let pool = kon_storage::init(&db_path).await?;
eprintln!("[kon-mcp] ready, waiting for JSON-RPC on stdin"); eprintln!("[kon-mcp] ready, waiting for JSON-RPC on stdin");

View File

@@ -62,6 +62,13 @@ pub async fn insert_transcript(
pool: &SqlitePool, pool: &SqlitePool,
params: &InsertTranscriptParams<'_>, params: &InsertTranscriptParams<'_>,
) -> Result<()> { ) -> Result<()> {
if !profile_exists(pool, params.profile_id).await? {
return Err(KonError::StorageError(format!(
"Insert transcript failed: unknown profile id '{}'",
params.profile_id
)));
}
sqlx::query( sqlx::query(
"INSERT INTO transcripts (id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination) "INSERT INTO transcripts (id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
@@ -731,6 +738,12 @@ pub async fn delete_profile(pool: &SqlitePool, id: &str) -> Result<()> {
"Default profile cannot be deleted".into(), "Default profile cannot be deleted".into(),
)); ));
} }
let transcript_count = transcript_count_for_profile(pool, id).await?;
if transcript_count > 0 {
return Err(KonError::StorageError(format!(
"Cannot delete profile while {transcript_count} transcript(s) still reference it; reassign transcripts first"
)));
}
sqlx::query("DELETE FROM profiles WHERE id = ?") sqlx::query("DELETE FROM profiles WHERE id = ?")
.bind(id) .bind(id)
.execute(pool) .execute(pool)
@@ -789,6 +802,23 @@ pub async fn delete_profile_term(pool: &SqlitePool, id: &str) -> Result<()> {
Ok(()) Ok(())
} }
async fn profile_exists(pool: &SqlitePool, id: &str) -> Result<bool> {
let exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM profiles WHERE id = ? LIMIT 1")
.bind(id)
.fetch_optional(pool)
.await
.map_err(|e| KonError::StorageError(format!("Profile existence check failed: {e}")))?;
Ok(exists.is_some())
}
async fn transcript_count_for_profile(pool: &SqlitePool, id: &str) -> Result<i64> {
sqlx::query_scalar("SELECT COUNT(*) FROM transcripts WHERE profile_id = ?")
.bind(id)
.fetch_one(pool)
.await
.map_err(|e| KonError::StorageError(format!("Profile transcript count failed: {e}")))
}
// --- Error Logging --- // --- Error Logging ---
/// Log a structured error to the `error_log` table. /// Log a structured error to the `error_log` table.
@@ -865,9 +895,14 @@ mod tests {
async fn test_pool() -> SqlitePool { async fn test_pool() -> SqlitePool {
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:") .connect("sqlite::memory:")
.await .await
.unwrap(); .unwrap();
sqlx::query("PRAGMA foreign_keys = ON")
.execute(&pool)
.await
.unwrap();
run_migrations(&pool).await.unwrap(); run_migrations(&pool).await.unwrap();
pool pool
} }
@@ -1047,11 +1082,19 @@ mod tests {
insert_task(&pool, "p1", "Ship release", "inbox", None, None, None) insert_task(&pool, "p1", "Ship release", "inbox", None, None, None)
.await .await
.unwrap(); .unwrap();
insert_subtask(&pool, "s1", "Final test", "p1").await.unwrap(); insert_subtask(&pool, "s1", "Final test", "p1")
insert_subtask(&pool, "s2", "Tag release", "p1").await.unwrap(); .await
.unwrap();
insert_subtask(&pool, "s2", "Tag release", "p1")
.await
.unwrap();
complete_subtask_and_check_parent(&pool, "s1").await.unwrap(); complete_subtask_and_check_parent(&pool, "s1")
complete_subtask_and_check_parent(&pool, "s2").await.unwrap(); .await
.unwrap();
complete_subtask_and_check_parent(&pool, "s2")
.await
.unwrap();
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap(); let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
assert!(parent.done); assert!(parent.done);
@@ -1353,6 +1396,72 @@ mod tests {
assert_eq!(profiles.len(), 1); assert_eq!(profiles.len(), 1);
} }
#[tokio::test]
async fn insert_transcript_rejects_unknown_profile_id() {
let pool = test_pool().await;
let err = insert_transcript(
&pool,
&InsertTranscriptParams {
id: "bad-profile",
text: "Hello",
source: "microphone",
profile_id: "profile-missing",
title: None,
audio_path: None,
duration: 0.0,
engine: None,
model_id: None,
inference_ms: None,
sample_rate: None,
audio_channels: None,
format_mode: None,
remove_fillers: false,
british_english: false,
anti_hallucination: false,
},
)
.await
.expect_err("unknown profile id must be rejected");
let msg = err.to_string();
assert!(msg.contains("unknown profile id"), "got: {msg}");
}
#[tokio::test]
async fn delete_profile_rejects_when_transcripts_reference_it() {
let pool = test_pool().await;
let profile = create_profile(&pool, "Referenced", "").await.unwrap();
insert_transcript(
&pool,
&InsertTranscriptParams {
id: "referenced-transcript",
text: "Hello",
source: "microphone",
profile_id: &profile.id,
title: None,
audio_path: None,
duration: 0.0,
engine: None,
model_id: None,
inference_ms: None,
sample_rate: None,
audio_channels: None,
format_mode: None,
remove_fillers: false,
british_english: false,
anti_hallucination: false,
},
)
.await
.unwrap();
let err = delete_profile(&pool, &profile.id)
.await
.expect_err("profile with transcript references must not delete");
let msg = err.to_string();
assert!(msg.contains("reassign transcripts first"), "got: {msg}");
}
#[tokio::test] #[tokio::test]
async fn list_profile_terms_happy_path() { async fn list_profile_terms_happy_path() {
let pool = test_pool().await; let pool = test_pool().await;

View File

@@ -215,6 +215,125 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
ON transcripts(profile_id); ON transcripts(profile_id);
"#, "#,
), ),
(
9,
"transcript_profile_fk",
r#"
INSERT OR IGNORE INTO profiles (id, name, initial_prompt, created_at)
VALUES ('00000000-0000-0000-0000-000000000001', 'Default', '', datetime('now'));
DROP TRIGGER IF EXISTS transcripts_ai;
DROP TRIGGER IF EXISTS transcripts_ad;
DROP TRIGGER IF EXISTS transcripts_au;
DROP TABLE IF EXISTS transcripts_fts;
DROP INDEX IF EXISTS idx_segments_transcript;
DROP INDEX IF EXISTS idx_transcripts_created;
DROP INDEX IF EXISTS idx_transcripts_profile_id;
ALTER TABLE segments RENAME TO segments_old;
ALTER TABLE transcripts RENAME TO transcripts_old;
CREATE TABLE transcripts (
id TEXT PRIMARY KEY,
text TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'microphone',
title TEXT,
audio_path TEXT,
duration REAL NOT NULL DEFAULT 0.0,
engine TEXT,
model_id TEXT,
inference_ms INTEGER,
sample_rate INTEGER,
audio_channels INTEGER,
format_mode TEXT,
remove_fillers INTEGER NOT NULL DEFAULT 0,
british_english INTEGER NOT NULL DEFAULT 0,
anti_hallucination INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
starred INTEGER NOT NULL DEFAULT 0,
manual_tags TEXT NOT NULL DEFAULT '',
template TEXT NOT NULL DEFAULT '',
language TEXT NOT NULL DEFAULT '',
segments_json TEXT NOT NULL DEFAULT '',
profile_id TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'
REFERENCES profiles(id) ON DELETE RESTRICT
);
CREATE INDEX idx_transcripts_created
ON transcripts(created_at);
CREATE INDEX idx_transcripts_profile_id
ON transcripts(profile_id);
INSERT INTO transcripts (
id, text, source, title, audio_path, duration, engine, model_id,
inference_ms, sample_rate, audio_channels, format_mode,
remove_fillers, british_english, anti_hallucination, created_at,
starred, manual_tags, template, language, segments_json, profile_id
)
SELECT
id, text, source, title, audio_path, duration, engine, model_id,
inference_ms, sample_rate, audio_channels, format_mode,
remove_fillers, british_english, anti_hallucination, created_at,
starred, manual_tags, template, language, segments_json,
CASE
WHEN profile_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM profiles
WHERE id = transcripts_old.profile_id
)
THEN profile_id
ELSE '00000000-0000-0000-0000-000000000001'
END
FROM transcripts_old;
CREATE TABLE segments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transcript_id TEXT NOT NULL REFERENCES transcripts(id) ON DELETE CASCADE,
start_time REAL NOT NULL,
end_time REAL NOT NULL,
text TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_segments_transcript
ON segments(transcript_id);
INSERT INTO segments (id, transcript_id, start_time, end_time, text)
SELECT id, transcript_id, start_time, end_time, text
FROM segments_old;
DROP TABLE segments_old;
DROP TABLE transcripts_old;
CREATE VIRTUAL TABLE transcripts_fts USING fts5(
text,
title,
content='transcripts',
content_rowid='rowid',
tokenize='porter unicode61 remove_diacritics 2'
);
CREATE TRIGGER transcripts_ai AFTER INSERT ON transcripts BEGIN
INSERT INTO transcripts_fts(rowid, text, title)
VALUES (new.rowid, new.text, COALESCE(new.title, ''));
END;
CREATE TRIGGER transcripts_ad AFTER DELETE ON transcripts BEGIN
INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title)
VALUES ('delete', old.rowid, old.text, COALESCE(old.title, ''));
END;
CREATE TRIGGER transcripts_au AFTER UPDATE ON transcripts BEGIN
INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title)
VALUES ('delete', old.rowid, old.text, COALESCE(old.title, ''));
INSERT INTO transcripts_fts(rowid, text, title)
VALUES (new.rowid, new.text, COALESCE(new.title, ''));
END;
INSERT INTO transcripts_fts(rowid, text, title)
SELECT rowid, text, COALESCE(title, '')
FROM transcripts;
"#,
),
]; ];
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks. /// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
@@ -281,10 +400,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
/// implicitly commits (`VACUUM`, `REINDEX`, `ATTACH`), it must be split /// implicitly commits (`VACUUM`, `REINDEX`, `ATTACH`), it must be split
/// out into its own non-transactional migration — reviewer's job to /// out into its own non-transactional migration — reviewer's job to
/// flag. /// flag.
async fn run_migrations_slice( async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)]) -> Result<()> {
pool: &SqlitePool,
migrations: &[(i64, &str, &str)],
) -> Result<()> {
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS schema_version ( "CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY, version INTEGER PRIMARY KEY,
@@ -344,12 +460,22 @@ mod tests {
use sqlx::sqlite::SqlitePoolOptions; use sqlx::sqlite::SqlitePoolOptions;
use sqlx::Row; use sqlx::Row;
#[tokio::test] async fn fk_test_pool() -> SqlitePool {
async fn test_migrations_run_on_empty_db() {
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:") .connect("sqlite::memory:")
.await .await
.unwrap(); .expect("pool");
sqlx::query("PRAGMA foreign_keys = ON")
.execute(&pool)
.await
.expect("enable foreign keys");
pool
}
#[tokio::test]
async fn test_migrations_run_on_empty_db() {
let pool = fk_test_pool().await;
run_migrations(&pool).await.unwrap(); run_migrations(&pool).await.unwrap();
@@ -357,7 +483,7 @@ mod tests {
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.unwrap(); .unwrap();
assert_eq!(count, 8); assert_eq!(count, 9);
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
.execute(&pool) .execute(&pool)
@@ -367,10 +493,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_migrations_idempotent() { async fn test_migrations_idempotent() {
let pool = SqlitePoolOptions::new() let pool = fk_test_pool().await;
.connect("sqlite::memory:")
.await
.unwrap();
run_migrations(&pool).await.unwrap(); run_migrations(&pool).await.unwrap();
run_migrations(&pool).await.unwrap(); run_migrations(&pool).await.unwrap();
@@ -379,7 +502,7 @@ mod tests {
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.unwrap(); .unwrap();
assert_eq!(count, 8); assert_eq!(count, 9);
} }
#[tokio::test] #[tokio::test]
@@ -440,11 +563,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn migration_transcript_profile_provenance_adds_profile_id() { async fn migration_transcript_profile_provenance_adds_profile_id() {
let pool = SqlitePoolOptions::new() let pool = fk_test_pool().await;
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
run_migrations(&pool).await.expect("migrate"); run_migrations(&pool).await.expect("migrate");
let info = sqlx::query("PRAGMA table_info(transcripts)") let info = sqlx::query("PRAGMA table_info(transcripts)")
@@ -459,11 +578,108 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn test_parent_task_id_cascade_delete() { async fn migration_v9_reconciles_orphaned_transcript_profiles_and_adds_fk() {
let pool = SqlitePoolOptions::new() let pool = fk_test_pool().await;
.connect("sqlite::memory:") run_migrations_up_to(&pool, 8).await.expect("migrate to v8");
sqlx::query(
"INSERT INTO profiles (id, name, initial_prompt, created_at)
VALUES ('profile-valid', 'Valid', '', datetime('now'))",
)
.execute(&pool)
.await .await
.unwrap(); .expect("seed valid profile");
sqlx::query(
"INSERT INTO transcripts (
id, text, source, title, audio_path, duration, engine, model_id,
inference_ms, sample_rate, audio_channels, format_mode,
remove_fillers, british_english, anti_hallucination, created_at,
starred, manual_tags, template, language, segments_json, profile_id
) VALUES (
't-orphan', 'orphan body', 'microphone', 'Orphan', NULL, 0.0, NULL, NULL,
NULL, NULL, NULL, NULL, 0, 0, 0, datetime('now'),
0, '', '', '', '', 'profile-missing'
)",
)
.execute(&pool)
.await
.expect("seed orphan transcript");
sqlx::query(
"INSERT INTO transcripts (
id, text, source, title, audio_path, duration, engine, model_id,
inference_ms, sample_rate, audio_channels, format_mode,
remove_fillers, british_english, anti_hallucination, created_at,
starred, manual_tags, template, language, segments_json, profile_id
) VALUES (
't-valid', 'valid body', 'microphone', 'Valid', NULL, 0.0, NULL, NULL,
NULL, NULL, NULL, NULL, 0, 0, 0, datetime('now'),
0, '', '', '', '', 'profile-valid'
)",
)
.execute(&pool)
.await
.expect("seed valid transcript");
sqlx::query(
"INSERT INTO segments (transcript_id, start_time, end_time, text)
VALUES ('t-orphan', 0.0, 1.0, 'segment')",
)
.execute(&pool)
.await
.expect("seed segment");
run_migrations(&pool).await.expect("migrate to v9");
let orphan_profile: String =
sqlx::query_scalar("SELECT profile_id FROM transcripts WHERE id = 't-orphan'")
.fetch_one(&pool)
.await
.expect("read healed orphan");
assert_eq!(orphan_profile, crate::DEFAULT_PROFILE_ID);
let valid_profile: String =
sqlx::query_scalar("SELECT profile_id FROM transcripts WHERE id = 't-valid'")
.fetch_one(&pool)
.await
.expect("read preserved profile");
assert_eq!(valid_profile, "profile-valid");
let segment_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM segments WHERE transcript_id = 't-orphan'")
.fetch_one(&pool)
.await
.expect("read migrated segments");
assert_eq!(segment_count, 1, "segments must survive transcript rebuild");
let fk_rows = sqlx::query("PRAGMA foreign_key_list(transcripts)")
.fetch_all(&pool)
.await
.expect("read transcript foreign keys");
assert!(
fk_rows.iter().any(|row| {
row.get::<String, _>("table") == "profiles"
&& row.get::<String, _>("from") == "profile_id"
&& row.get::<String, _>("to") == "id"
&& row.get::<String, _>("on_delete") == "RESTRICT"
}),
"transcripts.profile_id must reference profiles(id) with ON DELETE RESTRICT"
);
let fts_hits: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM transcripts_fts WHERE transcripts_fts MATCH 'orphan'",
)
.fetch_one(&pool)
.await
.expect("query rebuilt fts");
assert_eq!(
fts_hits, 1,
"fts index must be rebuilt for existing transcripts"
);
}
#[tokio::test]
async fn test_parent_task_id_cascade_delete() {
let pool = fk_test_pool().await;
run_migrations(&pool).await.unwrap(); run_migrations(&pool).await.unwrap();
@@ -656,7 +872,7 @@ mod tests {
run_migrations(&pool).await.expect("baseline migrate"); run_migrations(&pool).await.expect("baseline migrate");
const POISON: &[(i64, &str, &str)] = &[( const POISON: &[(i64, &str, &str)] = &[(
9, 10,
"rb-02 atomicity poison", "rb-02 atomicity poison",
r#" r#"
CREATE TABLE poison_marker (id INTEGER PRIMARY KEY); CREATE TABLE poison_marker (id INTEGER PRIMARY KEY);
@@ -680,15 +896,14 @@ mod tests {
"poison_marker must not exist; got: {marker:?}" "poison_marker must not exist; got: {marker:?}"
); );
// `schema_version` must not include v9 — version insert is part // `schema_version` must not include v10 — version insert is part
// of the same transaction that rolled back. // of the same transaction that rolled back.
let max: i64 = let max: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.expect("read schema_version"); .expect("read schema_version");
assert_eq!( assert_eq!(
max, 8, max, 9,
"schema_version must not advance past the failed migration" "schema_version must not advance past the failed migration"
); );
} }

View File

@@ -7,13 +7,13 @@ pub mod transcriber;
pub mod whisper_rs_backend; pub mod whisper_rs_backend;
pub use concurrency::run_inference; pub use concurrency::run_inference;
pub use local_engine::{load_parakeet, LocalEngine, SpeechModelAdapter, TimedTranscript};
#[cfg(feature = "whisper")] #[cfg(feature = "whisper")]
pub use local_engine::load_whisper; pub use local_engine::load_whisper;
pub use local_engine::{load_parakeet, LocalEngine, SpeechModelAdapter, TimedTranscript};
pub use model_manager::{download, is_downloaded, list_downloaded, model_dir, models_dir}; pub use model_manager::{download, is_downloaded, list_downloaded, model_dir, models_dir};
pub use streaming::{ pub use streaming::{
sample_index_for_seconds, trim_buffer_to_commit_point, CommitDecision, CommitPolicy, sample_index_for_seconds, trim_buffer_to_commit_point, CommitDecision, CommitPolicy,
LocalAgreement, RmsVadChunker, Token, VadChunk, VadChunker, LocalAgreement, RmsVadChunker, Token, VadChunk, VadChunker,
}; };
pub use transcriber::{Transcriber, TranscriberCapabilities};
pub use transcribe_rs::SpeechModel; pub use transcribe_rs::SpeechModel;
pub use transcriber::{Transcriber, TranscriberCapabilities};

View File

@@ -557,7 +557,10 @@ mod tests {
.expect_err("mismatched sha must fail"); .expect_err("mismatched sha must fail");
let msg = err.to_string(); let msg = err.to_string();
assert!(msg.contains("SHA256 mismatch"), "unexpected error: {msg}"); assert!(msg.contains("SHA256 mismatch"), "unexpected error: {msg}");
assert!(!dest.exists(), ".part → dest rename must not run on mismatch"); assert!(
!dest.exists(),
".part → dest rename must not run on mismatch"
);
let part = dest.with_extension("bin.part"); let part = dest.with_extension("bin.part");
assert!(!part.exists(), "failed hash must clean up the .part file"); assert!(!part.exists(), "failed hash must clean up the .part file");
} }

View File

@@ -108,11 +108,7 @@ impl LocalAgreement {
// Can't commit anything until we have n passes in hand. // Can't commit anything until we have n passes in hand.
if self.history.len() < self.n { if self.history.len() < self.n {
let tentative = self let tentative = self.history.back().cloned().unwrap_or_default();
.history
.back()
.cloned()
.unwrap_or_default();
return CommitDecision { return CommitDecision {
newly_committed: Vec::new(), newly_committed: Vec::new(),
tentative, tentative,

View File

@@ -552,10 +552,7 @@ mod tests {
let mut c = RmsVadChunker::new(); let mut c = RmsVadChunker::new();
assert!(c.flush().is_empty()); assert!(c.flush().is_empty());
let _ = c.push(&constant_signal(16_000, 0.0)); let _ = c.push(&constant_signal(16_000, 0.0));
assert!( assert!(c.flush().is_empty(), "flushing pure silence emits nothing");
c.flush().is_empty(),
"flushing pure silence emits nothing"
);
} }
#[test] #[test]

View File

@@ -62,10 +62,9 @@ impl Transcriber for WhisperRsBackend {
"WhisperRsBackend::transcribe_sync entering" "WhisperRsBackend::transcribe_sync entering"
); );
let mut state = self let mut state = self.ctx.create_state().map_err(|e| {
.ctx KonError::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string())
.create_state() })?;
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string()))?;
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
if let Some(lang) = options.language.as_deref() { if let Some(lang) = options.language.as_deref() {
@@ -83,9 +82,11 @@ impl Transcriber for WhisperRsBackend {
params.set_print_progress(false); params.set_print_progress(false);
params.set_print_realtime(false); params.set_print_realtime(false);
state state.full(params, samples).map_err(|e| {
.full(params, samples) KonError::TranscriptionFailed(
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::Transcribe(e.to_string()).to_string()))?; WhisperBackendError::Transcribe(e.to_string()).to_string(),
)
})?;
let n = state.full_n_segments(); let n = state.full_n_segments();
@@ -96,7 +97,11 @@ impl Transcriber for WhisperRsBackend {
}; };
let text = seg let text = seg
.to_str() .to_str()
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::Transcribe(e.to_string()).to_string()))? .map_err(|e| {
KonError::TranscriptionFailed(
WhisperBackendError::Transcribe(e.to_string()).to_string(),
)
})?
.to_string(); .to_string();
// whisper-rs timestamps are centiseconds (10ms units). Convert to seconds (f64). // whisper-rs timestamps are centiseconds (10ms units). Convert to seconds (f64).
let start = seg.start_timestamp() as f64 * 0.01; let start = seg.start_timestamp() as f64 * 0.01;

View File

@@ -11,43 +11,36 @@ Issues here must land before Kon v0.1 ships. Each is sourced from
`docs/code-review-2026-04-22.md`. When `gh` CLI is available, these `docs/code-review-2026-04-22.md`. When `gh` CLI is available, these
should be mirrored as real GitHub issues on `jakejars/kon`. should be mirrored as real GitHub issues on `jakejars/kon`.
## CRITICAL (2 open, 1 resolved) ## CRITICAL (0 open, 3 resolved)
No open CRITICAL blockers.
## MAJOR (1 open, 8 resolved)
| # | File | Area | Fix scope | | # | File | Area | Fix scope |
|---|---|---|---| |---|---|---|---|
| RB-01 | [c1-live-session-race.md](c1-live-session-race.md) | `src-tauri/commands/live.rs` | large |
| RB-03 | [c4-transcript-profile-fk.md](c4-transcript-profile-fk.md) | `crates/storage/migrations.rs` + `database.rs` | large |
## MAJOR (5 open, 4 resolved)
| # | File | Area | Fix scope |
|---|---|---|---|
| RB-04 | [run-live-session-monolith.md](run-live-session-monolith.md) | `src-tauri/commands/live.rs` | large |
| RB-05 | [poll-inference-channel-fatality.md](poll-inference-channel-fatality.md) | `src-tauri/commands/live.rs` | medium |
| RB-08 | [power-assertion-macos-objc2.md](power-assertion-macos-objc2.md) | `src-tauri/commands/power.rs` | medium | | RB-08 | [power-assertion-macos-objc2.md](power-assertion-macos-objc2.md) | `src-tauri/commands/power.rs` | medium |
| RB-10 | [llm-prompt-preflight.md](llm-prompt-preflight.md) | `crates/llm/lib.rs` | medium |
| RB-11 | [keystore-thread-safety.md](keystore-thread-safety.md) | `crates/cloud-providers/keystore.rs` | medium |
## Resolved ## Resolved
| # | File | Area | Resolution | | # | File | Area | Resolution |
|---|---|---|---| |---|---|---|---|
| RB-01 | [c1-live-session-race.md](c1-live-session-race.md) | `src-tauri/commands/live.rs` | Added `LiveTranscriptionState.lifecycle: tokio::sync::Mutex<()>` and hold it across the async spans of both `start_live_transcription_session` and `stop_live_transcription_session`. The running-slot check/insert and stop/take/join sequence are now serialized, so concurrent starts can no longer both pass the empty-slot check and a start during stop blocks until the previous worker fully joins. Two async regression tests cover both races. |
| RB-02 | [c3-migrations-atomicity.md](c3-migrations-atomicity.md) | `crates/storage/migrations.rs` | Each migration now runs inside a `pool.begin()` / `tx.commit()` transaction alongside its `schema_version` insert. Regression test injects a poisoned v9 migration and asserts neither the partial schema change nor the version row persists. DRY'd `run_migrations_up_to` test helper onto the same code path. | | RB-02 | [c3-migrations-atomicity.md](c3-migrations-atomicity.md) | `crates/storage/migrations.rs` | Each migration now runs inside a `pool.begin()` / `tx.commit()` transaction alongside its `schema_version` insert. Regression test injects a poisoned v9 migration and asserts neither the partial schema change nor the version row persists. DRY'd `run_migrations_up_to` test helper onto the same code path. |
| RB-03 | [c4-transcript-profile-fk.md](c4-transcript-profile-fk.md) | `crates/storage/migrations.rs` + `database.rs` | Added a transactional v9 rebuild of `transcripts` that enforces `profile_id REFERENCES profiles(id) ON DELETE RESTRICT`, reassigns any orphaned transcript provenance to `DEFAULT_PROFILE_ID`, rebuilds dependent `segments` / FTS state, and preserves valid profile references. `insert_transcript` now rejects unknown profile ids up front, and `delete_profile` returns a clear reassign-first error when transcripts still reference the profile. Regression tests cover migration reconciliation, invalid inserts, and delete rejection. |
| RB-04 | [run-live-session-monolith.md](run-live-session-monolith.md) | `src-tauri/commands/live.rs` | Replaced the 200+ line `run_live_session` loop with an explicit `LiveSessionRuntime` + `LiveLoopState` structure. Capture startup, runtime mic-error draining, audio chunk processing, overflow handling, stop-tail flush, inference dispatch/drain, and WAV finalisation each live in focused helpers, preserving behaviour while making the lifecycle auditable enough for RB-01 follow-up. Existing live tests and the full `kon` lib suite stay green. |
| RB-05 | [poll-inference-channel-fatality.md](poll-inference-channel-fatality.md) | `src-tauri/commands/live.rs` | `poll_inference` now treats result-channel loss as a listener-lifecycle problem rather than a transcription failure. On the first `result_channel.send(...)` error it marks the live result listener as lost, emits a single warning that transcription will continue in the background, and keeps processing later chunks without retrying the dead channel. Regression test simulates a dead result listener and asserts chunk processing continues with only one warning. |
| RB-06 | [native-capture-worker-join.md](native-capture-worker-join.md) | `src-tauri/commands/audio.rs` | `NativeCaptureState.stop_tx` replaced by `worker: AsyncMutex<Option<CaptureWorker>>`. `CaptureWorker` bundles the stop sender and the spawned task's `JoinHandle`; `stop_worker(worker)` sends stop then `await`s termination. Both `start_native_capture` (prior-worker stop) and `stop_native_capture` use the helper. Removed the 50ms sleep — the join barrier is exact. Two regression tests cover the lifecycle guarantee and the already-exited case. | | RB-06 | [native-capture-worker-join.md](native-capture-worker-join.md) | `src-tauri/commands/audio.rs` | `NativeCaptureState.stop_tx` replaced by `worker: AsyncMutex<Option<CaptureWorker>>`. `CaptureWorker` bundles the stop sender and the spawned task's `JoinHandle`; `stop_worker(worker)` sends stop then `await`s termination. Both `start_native_capture` (prior-worker stop) and `stop_native_capture` use the helper. Removed the 50ms sleep — the join barrier is exact. Two regression tests cover the lifecycle guarantee and the already-exited case. |
| RB-07 | [runtime-capabilities-accelerators.md](runtime-capabilities-accelerators.md) | `src-tauri/commands/models.rs` | Introduced `compose_accelerators(whisper_enabled, loader_available, target)` as a pure helper; `supported_accelerators()` reads `cfg(feature = "whisper")`, `vulkan_loader_available()`, and target OS then delegates. `get_runtime_capabilities` uses it in place of the hard-coded `["cpu", "vulkan"]`. Whisper's `supports_gpu` now follows the feature flag. Five regression tests cover all permutations. | | RB-07 | [runtime-capabilities-accelerators.md](runtime-capabilities-accelerators.md) | `src-tauri/commands/models.rs` | Introduced `compose_accelerators(whisper_enabled, loader_available, target)` as a pure helper; `supported_accelerators()` reads `cfg(feature = "whisper")`, `vulkan_loader_available()`, and target OS then delegates. `get_runtime_capabilities` uses it in place of the hard-coded `["cpu", "vulkan"]`. Whisper's `supports_gpu` now follows the feature flag. Five regression tests cover all permutations. |
| RB-09 | [decoder-partial-audio-on-error.md](decoder-partial-audio-on-error.md) | `crates/audio/decode.rs` | Packet-loop now propagates all non-EOF `SymphoniaError`s as `AudioDecodeFailed`; per-packet decode errors bubble via `?`. Mock-`MediaSource` regression test confirms mid-stream I/O errors surface instead of returning partial audio. | | RB-09 | [decoder-partial-audio-on-error.md](decoder-partial-audio-on-error.md) | `crates/audio/decode.rs` | Packet-loop now propagates all non-EOF `SymphoniaError`s as `AudioDecodeFailed`; per-packet decode errors bubble via `?`. Mock-`MediaSource` regression test confirms mid-stream I/O errors surface instead of returning partial audio. |
| RB-10 | [llm-prompt-preflight.md](llm-prompt-preflight.md) | `crates/llm/lib.rs` | Added an explicit prompt-budget preflight before context creation. If `prompt_tokens + max_tokens + reserve` exceeds the 8192-token cap, `generate` now returns a typed `EngineError::PromptTooLong { ... }` instead of failing late inside inference. Regression tests cover both the over-budget and exact-budget boundaries. |
| RB-11 | [keystore-thread-safety.md](keystore-thread-safety.md) | `crates/cloud-providers/keystore.rs` | Replaced the `std::env::set_var` stub with a process-global `OnceLock<Mutex<HashMap<...>>>` keystore, keeping the API safe from any thread. Retrieval still falls back to read-only `KON_API_KEY_*` env vars for externally supplied secrets. Two regression tests cover store/retrieve and provider isolation. |
| RB-12 | [hotkey-linux-device-filter.md](hotkey-linux-device-filter.md) | `crates/hotkey/linux.rs` | Extracted `device_supports_combo` helper; `try_attach_device` now reads the configured `HotkeyCombo` from the watch channel and checks support for that trigger key. Four regression tests land in `linux::tests`. | | RB-12 | [hotkey-linux-device-filter.md](hotkey-linux-device-filter.md) | `crates/hotkey/linux.rs` | Extracted `device_supports_combo` helper; `try_attach_device` now reads the configured `HotkeyCombo` from the watch channel and checks support for that trigger key. Four regression tests land in `linux::tests`. |
## Dependencies ## Remaining blocker
``` `RB-08` remains open pending manual runtime verification on a real macOS
RB-01 (live session race) machine (`pmset -g assertions`, background live-session sanity check).
└── blocked by RB-04 (run_live_session monolith refactor)
RB-03 (transcript-profile FK)
└── coupled with RB-02 (migrations atomicity)
— a v9 migration adding the FK constraint must be transactional
```
## How to convert to GitHub issues ## How to convert to GitHub issues

View File

@@ -4,6 +4,31 @@
**Path:** `src-tauri/src/commands/live.rs:193-338` **Path:** `src-tauri/src/commands/live.rs:193-338`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md#c1--racy-single-session-guard-in-livers) **Source:** [2026-04-22 code review](../code-review-2026-04-22.md#c1--racy-single-session-guard-in-livers)
**Labels:** release-blocker, critical, concurrency **Labels:** release-blocker, critical, concurrency
**Status:** RESOLVED (2026-04-22)
## Resolution
`LiveTranscriptionState` now includes a dedicated
`tokio::sync::Mutex<()>` lifecycle gate. Both
`start_live_transcription_session` and
`stop_live_transcription_session` acquire that async mutex before
touching `running`, and they keep it held across the awaited setup /
join work that previously exposed the race windows.
That changes the two failing interleavings from the review:
- Two overlapping starts no longer race through the empty-slot check.
The second call waits for the first to finish setup, then observes
`running.is_some()` and returns the existing
`"A live transcription session is already running"` error.
- A start launched during stop can no longer sneak in after
`running.take()` but before the previous worker has fully joined.
It blocks on the lifecycle mutex until the join completes.
Regression tests in `commands::live::tests`:
- `concurrent_starts_allow_only_one_session_to_claim_the_slot`
- `start_waits_for_stop_to_finish_joining_before_reusing_slot`
## Problem ## Problem
@@ -26,4 +51,4 @@ Large. Will likely require the `run_live_session` monolith refactor (RB-04) to l
## Dependencies ## Dependencies
- **Blocked by:** RB-04 (`run_live_session` monolith refactor) - Landed after RB-04 (`run_live_session` refactor) made the worker lifecycle explicit enough to guard safely.

View File

@@ -4,6 +4,33 @@
**Path:** `crates/storage/src/migrations.rs:208-216`, `crates/storage/src/database.rs:61-89`, `:697-708` **Path:** `crates/storage/src/migrations.rs:208-216`, `crates/storage/src/database.rs:61-89`, `:697-708`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md#c4--transcript-provenance-can-reference-deleted-profiles) **Source:** [2026-04-22 code review](../code-review-2026-04-22.md#c4--transcript-provenance-can-reference-deleted-profiles)
**Labels:** release-blocker, critical, data-integrity, storage **Labels:** release-blocker, critical, data-integrity, storage
**Status:** RESOLVED (2026-04-22)
## Resolution
Chose the strict provenance path:
- Migration v9 rebuilds `transcripts` with
`profile_id REFERENCES profiles(id) ON DELETE RESTRICT`.
- Existing orphaned transcript `profile_id` values are reconciled onto
`DEFAULT_PROFILE_ID` during the copy into the rebuilt table.
- Because SQLite table renames rewrite dependent references, the
migration also rebuilds `segments`, recreates the transcript FTS
virtual table/triggers, and repopulates FTS from the rebuilt
transcript rows inside the same transaction.
Application-layer behaviour now matches the schema:
- `insert_transcript` rejects unknown `profile_id` values with a clear
storage error before attempting the insert.
- `delete_profile` returns a human-readable reassign-first error when
transcripts still reference that profile.
Regression tests:
- `migration_v9_reconciles_orphaned_transcript_profiles_and_adds_fk`
- `insert_transcript_rejects_unknown_profile_id`
- `delete_profile_rejects_when_transcripts_reference_it`
## Problem ## Problem

View File

@@ -4,6 +4,28 @@
**Path:** `crates/cloud-providers/src/keystore.rs:6-18` **Path:** `crates/cloud-providers/src/keystore.rs:6-18`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md) **Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
**Labels:** release-blocker, major, unsafe-api, cloud **Labels:** release-blocker, major, unsafe-api, cloud
**Status:** RESOLVED (2026-04-22)
## Resolution
Chose acceptance option 2. The environment-mutation stub is gone;
`store_api_key` now writes into a process-global
`OnceLock<Mutex<HashMap<String, String>>>`, so the safe signature matches
the actual safety properties.
Additional details:
- Stored keys now live in-memory only for the life of the process.
- `retrieve_api_key` checks the in-memory keystore first, then falls
back to read-only `KON_API_KEY_<PROVIDER>` environment variables so
externally injected secrets still work.
- Module docs now describe the real tradeoff clearly: safe from any
thread, but non-persistent until a proper OS keychain backend lands.
Regression tests:
- `stored_key_is_retrievable_without_env_mutation`
- `providers_do_not_overlap`
## Problem ## Problem

View File

@@ -4,6 +4,25 @@
**Path:** `crates/llm/src/lib.rs:143-166`, `:317-321` **Path:** `crates/llm/src/lib.rs:143-166`, `:317-321`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md) **Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
**Labels:** release-blocker, major, llm **Labels:** release-blocker, major, llm
**Status:** RESOLVED (2026-04-22)
## Resolution
`LlmEngine::generate` still tokenises the whole prompt up front, but it
now runs a dedicated prompt-budget preflight before creating the llama
context. The chosen behaviour is an early typed failure rather than
silent truncation:
- If `prompt_tokens + max_tokens + 64 reserve tokens` exceeds the
8192-token cap, generation returns
`EngineError::PromptTooLong { prompt_tokens, max_tokens, available_prompt_tokens, context_window }`.
- Prompts that fit exactly within the available budget still proceed and
allocate an 8192-token context as before.
Regression tests:
- `prompt_preflight_rejects_oversized_prompt_tokens`
- `prompt_preflight_keeps_prompts_within_budget`
## Problem ## Problem

View File

@@ -4,6 +4,31 @@
**Path:** `src-tauri/src/commands/live.rs:721-813` **Path:** `src-tauri/src/commands/live.rs:721-813`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md) **Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
**Labels:** release-blocker, major, ipc-lifecycle **Labels:** release-blocker, major, ipc-lifecycle
**Status:** RESOLVED (2026-04-22)
## Resolution
`poll_inference` no longer propagates `result_channel.send(...)` with `?`.
Instead, live-result delivery is routed through a small helper that
tracks whether the frontend listener has already been lost:
- First send failure: mark the result listener as unavailable, log a
warning, and best-effort send a `LiveStatusMessage::Warning`
explaining that transcription will continue in the background until
the user stops the session.
- Subsequent chunks: skip re-sending to the dead result channel and
keep the worker running.
Crucially, this path is now separate from actual transcription failure:
inference errors still emit `LiveStatusMessage::Error` and stop the
session, while listener-loss just stops live preview delivery.
Regression test:
- `result_listener_loss_is_warned_once_and_not_treated_as_inference_failure`
simulates a dead result channel, confirms the first processed chunk
downgrades to a warning, and confirms a second chunk still processes
successfully without a second warning.
## Problem ## Problem

View File

@@ -5,6 +5,18 @@
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md), originally deferred during A.1 #9 **Source:** [2026-04-22 code review](../code-review-2026-04-22.md), originally deferred during A.1 #9
**Labels:** release-blocker, major, macos, platform **Labels:** release-blocker, major, macos, platform
**Current state (2026-04-23):** `objc2`/`objc2-foundation` have been
added behind `cfg(target_os = "macos")`, and the `NSProcessInfo`
bridge now calls `beginActivityWithOptions:reason:` / `endActivity:`
with the retained activity handle. Isolated `cargo check` validation
passes for both `x86_64-apple-darwin` and `aarch64-apple-darwin`.
Remaining acceptance gap: manual runtime verification on a real macOS
machine (`pmset -g assertions`, background live session). Diagnostic
reports now also include a `## Power assertions` section that lists any
currently active Kon assertion guards (`reason`, `backend`, `acquired`)
at report time, which gives the tester an in-app breadcrumb alongside
`pmset`.
## Problem ## Problem
`begin_activity` always returns `Err` on macOS, so `PowerAssertion::begin` converts to `None` and the guard never acquires an `NSProcessInfo beginActivityWithOptions:reason:` assertion. Live recording and LLM cleanup therefore run without App Nap protection on the one platform where it matters. `begin_activity` always returns `Err` on macOS, so `PowerAssertion::begin` converts to `None` and the guard never acquires an `NSProcessInfo beginActivityWithOptions:reason:` assertion. Live recording and LLM cleanup therefore run without App Nap protection on the one platform where it matters.
@@ -18,6 +30,21 @@ The stub was deliberate (A.1 #9 acceptance concession — untestable on Linux wi
- `end_activity` calls `endActivity:` on the retained handle. - `end_activity` calls `endActivity:` on the retained handle.
- Manual-test on a real macOS box: 10-minute background live session completes without throttling; `pmset -g assertions` shows Kon's activity during capture. - Manual-test on a real macOS box: 10-minute background live session completes without throttling; `pmset -g assertions` shows Kon's activity during capture.
## Manual verification checklist
1. Launch Kon on a real macOS machine and start a live transcription session.
2. While capture is running, background the app for at least several minutes.
3. In Terminal, run `pmset -g assertions` and confirm Kon appears with a
user-initiated / no-idle-style assertion while the session is active.
4. While the session is still running, generate a Kon diagnostic report
and confirm the `## Power assertions` section lists an active entry
such as `reason=kon live dictation session`, `backend=macos`,
`acquired=true`.
5. Stop the session and rerun `pmset -g assertions` or regenerate the
diagnostic report to confirm the assertion disappears.
6. Repeat once for the LLM cleanup path if desired
(`reason=kon LLM cleanup`).
## Fix scope ## Fix scope
Medium. Dep addition + FFI glue + manual verification. Can be done from Linux with `cargo check --target=aarch64-apple-darwin` for compile validation, but runtime behaviour needs a macOS machine. Medium. Dep addition + FFI glue + manual verification. Can be done from Linux with `cargo check --target=aarch64-apple-darwin` for compile validation, but runtime behaviour needs a macOS machine.

View File

@@ -4,6 +4,33 @@
**Path:** `src-tauri/src/commands/live.rs:349-579` **Path:** `src-tauri/src/commands/live.rs:349-579`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md) **Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
**Labels:** release-blocker, major, refactor, concurrency **Labels:** release-blocker, major, refactor, concurrency
**Status:** RESOLVED (2026-04-22)
## Resolution
`run_live_session` was split around two explicit state holders:
- `ActiveCapture` owns the cpal stream handle, audio receiver, and
optional runtime-error channel.
- `LiveLoopState` owns the mutable per-session loop state: resampler,
capture buffer, WAV writer, buffer offsets, dropped-audio accounting,
in-flight inference task, and duplicate-history buffer.
The top-level worker is now `LiveSessionRuntime`, with focused methods
for:
- polling in-flight inference
- draining microphone runtime warnings
- receiving + resampling an audio chunk
- dropping pending-buffer overflow
- flushing the resampler tail when stop is requested
- dispatching inference when enough audio is buffered
- draining the last in-flight task
- finalising the progressive WAV writer
This keeps behaviour intact but removes the "everything in one mutable
loop" shape that made concurrency review hard. The refactor also made
RB-01 straightforward enough to land immediately afterward.
## Problem ## Problem

View File

@@ -74,3 +74,7 @@ webkit2gtk = "2.0"
# transitively depends on (GTK 3). # transitively depends on (GTK 3).
gtk = "0.18" gtk = "0.18"
gdk = "0.18" gdk = "0.18"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.4"
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSString", "NSProcessInfo"] }

View File

@@ -33,8 +33,8 @@ fn assert_localhost_llm_csp() {
let raw = std::fs::read_to_string(conf_path) let raw = std::fs::read_to_string(conf_path)
.expect("build.rs: failed to read tauri.conf.json for CSP regression guard"); .expect("build.rs: failed to read tauri.conf.json for CSP regression guard");
let conf: serde_json::Value = serde_json::from_str(&raw) let conf: serde_json::Value =
.expect("build.rs: tauri.conf.json is not valid JSON"); serde_json::from_str(&raw).expect("build.rs: tauri.conf.json is not valid JSON");
let csp = conf let csp = conf
.pointer("/app/security/csp") .pointer("/app/security/csp")

File diff suppressed because one or more lines are too long

View File

@@ -2313,22 +2313,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
}, },
{ {
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string", "type": "string",
"const": "dialog:default", "const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
}, },
{ {
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", "description": "Enables the ask command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "dialog:allow-ask", "const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" "markdownDescription": "Enables the ask command without any pre-configured scope."
}, },
{ {
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", "description": "Enables the confirm command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "dialog:allow-confirm", "const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" "markdownDescription": "Enables the confirm command without any pre-configured scope."
}, },
{ {
"description": "Enables the message command without any pre-configured scope.", "description": "Enables the message command without any pre-configured scope.",
@@ -2349,16 +2349,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope." "markdownDescription": "Enables the save command without any pre-configured scope."
}, },
{ {
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", "description": "Denies the ask command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "dialog:deny-ask", "const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" "markdownDescription": "Denies the ask command without any pre-configured scope."
}, },
{ {
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", "description": "Denies the confirm command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "dialog:deny-confirm", "const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" "markdownDescription": "Denies the confirm command without any pre-configured scope."
}, },
{ {
"description": "Denies the message command without any pre-configured scope.", "description": "Denies the message command without any pre-configured scope.",

View File

@@ -2313,22 +2313,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
}, },
{ {
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string", "type": "string",
"const": "dialog:default", "const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
}, },
{ {
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", "description": "Enables the ask command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "dialog:allow-ask", "const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" "markdownDescription": "Enables the ask command without any pre-configured scope."
}, },
{ {
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", "description": "Enables the confirm command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "dialog:allow-confirm", "const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" "markdownDescription": "Enables the confirm command without any pre-configured scope."
}, },
{ {
"description": "Enables the message command without any pre-configured scope.", "description": "Enables the message command without any pre-configured scope.",
@@ -2349,16 +2349,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope." "markdownDescription": "Enables the save command without any pre-configured scope."
}, },
{ {
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", "description": "Denies the ask command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "dialog:deny-ask", "const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" "markdownDescription": "Denies the ask command without any pre-configured scope."
}, },
{ {
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", "description": "Denies the confirm command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "dialog:deny-confirm", "const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" "markdownDescription": "Denies the confirm command without any pre-configured scope."
}, },
{ {
"description": "Denies the message command without any pre-configured scope.", "description": "Denies the message command without any pre-configured scope.",

View File

@@ -308,8 +308,7 @@ fn recording_filename() -> String {
/// restarts, so cross-launch collisions are already impossible — the /// restarts, so cross-launch collisions are already impossible — the
/// counter is the last-mile guarantee against within-launch same-tick /// counter is the last-mile guarantee against within-launch same-tick
/// collisions. /// collisions.
static RECORDING_COUNTER: std::sync::atomic::AtomicU64 = static RECORDING_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
std::sync::atomic::AtomicU64::new(0);
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -353,10 +352,20 @@ mod tests {
3, 3,
"expected three '-' separated parts, got {parts:?}" "expected three '-' separated parts, got {parts:?}"
); );
assert!(parts[0].chars().all(|c| c.is_ascii_digit()), "secs is digits"); assert!(
assert_eq!(parts[1].len(), 9, "nanos component is zero-padded to 9 digits"); parts[0].chars().all(|c| c.is_ascii_digit()),
"secs is digits"
);
assert_eq!(
parts[1].len(),
9,
"nanos component is zero-padded to 9 digits"
);
assert!(parts[1].chars().all(|c| c.is_ascii_digit())); assert!(parts[1].chars().all(|c| c.is_ascii_digit()));
assert!(parts[2].len() >= 4, "counter component is zero-padded to >=4 digits"); assert!(
parts[2].len() >= 4,
"counter component is zero-padded to >=4 digits"
);
assert!(parts[2].chars().all(|c| c.is_ascii_digit())); assert!(parts[2].chars().all(|c| c.is_ascii_digit()));
} }

View File

@@ -19,6 +19,7 @@ use kon_storage::{
app_data_dir, crashes_dir, list_recent_errors, log_error, logs_dir, ErrorLogRow, app_data_dir, crashes_dir, list_recent_errors, log_error, logs_dir, ErrorLogRow,
}; };
use crate::commands::power::active_assertions_snapshot;
use crate::AppState; use crate::AppState;
const DEFAULT_RECENT_ERRORS: i64 = 50; const DEFAULT_RECENT_ERRORS: i64 = 50;
@@ -290,6 +291,20 @@ pub async fn generate_diagnostic_report(
} }
} }
out.push_str("## Power assertions\n\n");
let power_assertions = active_assertions_snapshot();
if power_assertions.is_empty() {
out.push_str("_(no active power assertions at report time)_\n\n");
} else {
for assertion in power_assertions {
out.push_str(&format!(
"- `#{}` reason=`{}` backend=`{}` acquired=`{}`\n",
assertion.id, assertion.reason, assertion.backend, assertion.acquired
));
}
out.push('\n');
}
if opts.include_crashes { if opts.include_crashes {
out.push_str("## Crash dumps\n\n"); out.push_str("## Crash dumps\n\n");
let crashes = list_crash_files().await.unwrap_or_default(); let crashes = list_crash_files().await.unwrap_or_default();

View File

@@ -11,6 +11,7 @@ use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::ipc::Channel; use tauri::ipc::Channel;
use tokio::sync::Mutex as AsyncMutex;
use crate::commands::audio::resolve_recording_path; use crate::commands::audio::resolve_recording_path;
use crate::commands::build_initial_prompt; use crate::commands::build_initial_prompt;
@@ -18,7 +19,9 @@ use crate::commands::models::{default_model_id_for_engine, ensure_model_loaded};
use crate::commands::power::PowerAssertion; use crate::commands::power::PowerAssertion;
use crate::AppState; use crate::AppState;
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions}; use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
use kon_audio::{MicrophoneCapture, StreamingResampler, WavWriter}; use kon_audio::{
AudioChunk, CaptureRuntimeError, MicrophoneCapture, StreamingResampler, WavWriter,
};
use kon_core::constants::WHISPER_SAMPLE_RATE; use kon_core::constants::WHISPER_SAMPLE_RATE;
use kon_core::types::{AudioSamples, Segment, TranscriptionOptions}; use kon_core::types::{AudioSamples, Segment, TranscriptionOptions};
use kon_transcription::LocalEngine; use kon_transcription::LocalEngine;
@@ -57,6 +60,7 @@ const LOW_SIGNAL_TOKENS: &[&str] = &[
#[derive(Default)] #[derive(Default)]
pub struct LiveTranscriptionState { pub struct LiveTranscriptionState {
next_session_id: AtomicU64, next_session_id: AtomicU64,
lifecycle: AsyncMutex<()>,
running: Mutex<Option<RunningLiveSession>>, running: Mutex<Option<RunningLiveSession>>,
} }
@@ -153,6 +157,292 @@ struct LiveSessionSummary {
audio_path: Option<String>, audio_path: Option<String>,
} }
/// Session worker state is thread-confined to the single blocking task spawned
/// by `start_live_transcription_session`. Cross-thread coordination happens via
/// the stop flag and mpsc channels only; RB-01 will tighten the outer
/// `live_state.running` lock discipline now that this worker lifecycle is
/// explicit and locally structured.
struct ActiveCapture {
/// Keeping the capture handle alive keeps the underlying cpal stream alive.
_capture: MicrophoneCapture,
rx: std::sync::mpsc::Receiver<AudioChunk>,
mic_error_rx: Option<std::sync::mpsc::Receiver<CaptureRuntimeError>>,
}
impl ActiveCapture {
fn start(config: &StartLiveTranscriptionConfig) -> Result<Self, String> {
let (mut capture, rx) = match config.microphone_device.as_deref() {
Some(name) if !name.is_empty() => MicrophoneCapture::start_with_device(name),
_ => MicrophoneCapture::start(),
}
.map_err(|e| e.to_string())?;
let mic_error_rx = capture.take_error_rx();
Ok(Self {
_capture: capture,
rx,
mic_error_rx,
})
}
fn drain_runtime_errors(
&mut self,
session_id: u64,
status_channel: &Channel<LiveStatusMessage>,
) {
let Some(err_rx) = &self.mic_error_rx else {
return;
};
while let Ok(err) = err_rx.try_recv() {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
message: format!(
"Microphone '{}' reported an error: {}",
err.device_name, err.message
),
});
}
}
}
#[derive(Default)]
struct LiveLoopState {
resampler: Option<StreamingResampler>,
capture_buffer: Vec<f32>,
wav_writer: Option<WavWriter>,
buffer_start_sample: u64,
dropped_audio_ms: u64,
chunk_id: u32,
inflight: Option<InferenceTask>,
resampler_flushed: bool,
result_listener_lost: bool,
recent_segments: Vec<RecentTranscriptSegment>,
}
impl LiveLoopState {
fn new(wav_writer: Option<WavWriter>) -> Self {
Self {
wav_writer,
..Self::default()
}
}
}
struct LiveSessionRuntime {
session_id: u64,
engine: Arc<LocalEngine>,
config: StartLiveTranscriptionConfig,
audio_path: Option<PathBuf>,
dictionary_terms: Vec<String>,
result_channel: Channel<LiveResultMessage>,
status_channel: Channel<LiveStatusMessage>,
stop_flag: Arc<AtomicBool>,
capture: ActiveCapture,
state: LiveLoopState,
}
impl LiveSessionRuntime {
fn new(
session_id: u64,
engine: Arc<LocalEngine>,
config: StartLiveTranscriptionConfig,
audio_path: Option<PathBuf>,
dictionary_terms: Vec<String>,
result_channel: Channel<LiveResultMessage>,
status_channel: Channel<LiveStatusMessage>,
stop_flag: Arc<AtomicBool>,
) -> Result<Self, String> {
let capture = ActiveCapture::start(&config)?;
let wav_writer = open_wav_writer(&engine, audio_path.as_ref(), session_id, &status_channel);
Ok(Self {
session_id,
engine,
config,
audio_path,
dictionary_terms,
result_channel,
status_channel,
stop_flag,
capture,
state: LiveLoopState::new(wav_writer),
})
}
fn run(mut self) -> Result<LiveSessionSummary, String> {
loop {
self.poll_inference()?;
self.capture
.drain_runtime_errors(self.session_id, &self.status_channel);
if let Some(chunk) = self.recv_audio()? {
self.process_audio_chunk(chunk)?;
}
self.drop_pending_overflow();
self.flush_tail_if_stopping()?;
if self.dispatch_inference_if_ready() {
continue;
}
if self.should_exit_loop() {
break;
}
}
self.drain_inference()?;
self.finish()
}
fn poll_inference(&mut self) -> Result<(), String> {
let _ = poll_inference(
&mut self.state.inflight,
&mut self.state.result_listener_lost,
self.session_id,
&self.config,
&mut self.state.recent_segments,
&self.dictionary_terms,
&self.result_channel,
&self.status_channel,
)?;
Ok(())
}
fn recv_audio(&mut self) -> Result<Option<AudioChunk>, String> {
match self.capture.rx.recv_timeout(Duration::from_millis(25)) {
Ok(chunk) => Ok(Some(chunk)),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Ok(None),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
let message = "Microphone capture disconnected unexpectedly".to_string();
let _ = self.status_channel.send(LiveStatusMessage::Error {
session_id: self.session_id,
message: message.clone(),
});
Err(message)
}
}
}
fn process_audio_chunk(&mut self, chunk: AudioChunk) -> Result<(), String> {
let mono = downmix_chunk(chunk.samples, chunk.channels as usize);
let resampler = match &mut self.state.resampler {
Some(resampler) => resampler,
None => {
self.state.resampler =
Some(StreamingResampler::new(chunk.sample_rate).map_err(|e| e.to_string())?);
self.state.resampler.as_mut().expect("resampler just set")
}
};
let resampled = resampler.push_samples(&mono).map_err(|e| e.to_string())?;
append_resampled_audio(
&mut self.state.capture_buffer,
&mut self.state.wav_writer,
&resampled,
self.session_id,
&self.status_channel,
);
Ok(())
}
fn drop_pending_overflow(&mut self) {
if self.state.inflight.is_none() || self.state.capture_buffer.len() <= MAX_PENDING_SAMPLES {
return;
}
let overflow = self.state.capture_buffer.len() - MAX_PENDING_SAMPLES;
self.state.capture_buffer.drain(..overflow);
self.state.buffer_start_sample = self
.state
.buffer_start_sample
.saturating_add(overflow as u64);
self.state.dropped_audio_ms = self
.state
.dropped_audio_ms
.saturating_add((overflow as u64 * 1000) / WHISPER_SAMPLE_RATE as u64);
let _ = self.status_channel.send(LiveStatusMessage::Overload {
session_id: self.session_id,
dropped_audio_ms: self.state.dropped_audio_ms,
message: "Kon dropped older audio to keep live dictation responsive".into(),
});
}
fn flush_tail_if_stopping(&mut self) -> Result<(), String> {
if !self.stopping() || self.state.resampler_flushed {
return Ok(());
}
if let Some(resampler) = &mut self.state.resampler {
let tail = resampler.flush().map_err(|e| e.to_string())?;
append_resampled_audio(
&mut self.state.capture_buffer,
&mut self.state.wav_writer,
&tail,
self.session_id,
&self.status_channel,
);
}
self.flush_wav_header();
self.state.resampler_flushed = true;
Ok(())
}
fn flush_wav_header(&mut self) {
let Some(writer) = self.state.wav_writer.as_mut() else {
return;
};
if let Err(e) = writer.flush() {
let _ = self.status_channel.send(LiveStatusMessage::Warning {
session_id: self.session_id,
message: format!("WAV flush failed near session end: {e}"),
});
}
}
fn dispatch_inference_if_ready(&mut self) -> bool {
if self.state.inflight.is_some() {
return false;
}
let stopping = self.stopping();
if let Some(task) = maybe_dispatch_chunk(
&self.engine,
&self.config,
&mut self.state.capture_buffer,
&mut self.state.buffer_start_sample,
&mut self.state.chunk_id,
stopping,
&self.status_channel,
self.session_id,
) {
self.state.inflight = Some(task);
return true;
}
false
}
fn stopping(&self) -> bool {
self.stop_flag.load(Ordering::Relaxed)
}
fn should_exit_loop(&self) -> bool {
self.stopping() && self.state.resampler_flushed && self.state.inflight.is_none()
}
fn drain_inference(&mut self) -> Result<(), String> {
while self.state.inflight.is_some() {
self.poll_inference()?;
thread::sleep(Duration::from_millis(10));
}
Ok(())
}
fn finish(mut self) -> Result<LiveSessionSummary, String> {
let audio_path = finalize_wav_writer(
self.state.wav_writer.take(),
self.audio_path.as_ref(),
self.session_id,
&self.status_channel,
);
Ok(LiveSessionSummary {
session_id: self.session_id,
dropped_audio_ms: self.state.dropped_audio_ms,
audio_path,
})
}
}
struct InferenceTask { struct InferenceTask {
chunk_id: u32, chunk_id: u32,
chunk_start_sample: u64, chunk_start_sample: u64,
@@ -198,6 +488,7 @@ pub async fn start_live_transcription_session(
result_channel: Channel<LiveResultMessage>, result_channel: Channel<LiveResultMessage>,
status_channel: Channel<LiveStatusMessage>, status_channel: Channel<LiveStatusMessage>,
) -> Result<StartLiveTranscriptionResponse, String> { ) -> Result<StartLiveTranscriptionResponse, String> {
let _lifecycle = live_state.lifecycle.lock().await;
{ {
let running = live_state.running.lock().unwrap(); let running = live_state.running.lock().unwrap();
if running.is_some() { if running.is_some() {
@@ -227,11 +518,8 @@ pub async fn start_live_transcription_session(
// `TranscriptionOptions` construction (see `maybe_dispatch_chunk`) picks // `TranscriptionOptions` construction (see `maybe_dispatch_chunk`) picks
// up profile fallback + vocabulary injection without further plumbing. // up profile fallback + vocabulary injection without further plumbing.
let request_prompt = config.initial_prompt.clone().unwrap_or_default(); let request_prompt = config.initial_prompt.clone().unwrap_or_default();
config.initial_prompt = build_initial_prompt( config.initial_prompt =
&request_prompt, build_initial_prompt(&request_prompt, &profile.initial_prompt, &profile_terms);
&profile.initial_prompt,
&profile_terms,
);
let model_id = config let model_id = config
.model_id .model_id
@@ -258,7 +546,10 @@ pub async fn start_live_transcription_session(
// for save_audio=true and silently dropping the recording would // for save_audio=true and silently dropping the recording would
// surprise them worse. // surprise them worse.
let audio_path = if config.save_audio { let audio_path = if config.save_audio {
Some(resolve_recording_path(&app, config.output_folder.as_deref())?) Some(resolve_recording_path(
&app,
config.output_folder.as_deref(),
)?)
} else { } else {
None None
}; };
@@ -299,6 +590,7 @@ pub async fn stop_live_transcription_session(
live_state: tauri::State<'_, LiveTranscriptionState>, live_state: tauri::State<'_, LiveTranscriptionState>,
session_id: u64, session_id: u64,
) -> Result<StopLiveTranscriptionResponse, String> { ) -> Result<StopLiveTranscriptionResponse, String> {
let _lifecycle = live_state.lifecycle.lock().await;
let running = live_state.running.lock().unwrap().take(); let running = live_state.running.lock().unwrap().take();
let Some(running) = running else { let Some(running) = running else {
return Err("No live transcription session is running".into()); return Err("No live transcription session is running".into());
@@ -360,34 +652,32 @@ fn run_live_session(
// lifetime to the session — when the function returns, the Drop // lifetime to the session — when the function returns, the Drop
// impl lifts it. Item #9 in docs/whisper-ecosystem/brief.md. // impl lifts it. Item #9 in docs/whisper-ecosystem/brief.md.
let _power_guard = PowerAssertion::begin("kon live dictation session"); let _power_guard = PowerAssertion::begin("kon live dictation session");
LiveSessionRuntime::new(
session_id,
engine,
config,
audio_path,
dictionary_terms,
result_channel,
status_channel,
stop_flag,
)?
.run()
}
let (mut capture, rx) = match config.microphone_device.as_deref() { fn open_wav_writer(
Some(name) if !name.is_empty() => MicrophoneCapture::start_with_device(name), engine: &Arc<LocalEngine>,
_ => MicrophoneCapture::start(), audio_path: Option<&PathBuf>,
} session_id: u64,
.map_err(|e| e.to_string())?; status_channel: &Channel<LiveStatusMessage>,
// Drain runtime stream errors into the status channel so the user ) -> Option<WavWriter> {
// gets a toast when the device disconnects mid-recording instead of
// silently producing empty transcripts. The `_capture` binding keeps
// the cpal stream alive for the duration of the session.
let mic_error_rx = capture.take_error_rx();
let _capture = capture;
let mut resampler: Option<StreamingResampler> = None;
let mut capture_buffer: Vec<f32> = Vec::new();
// Progressive WAV writer (brief item #19). Sample rate comes from
// the loaded backend's capabilities (#13 wiring) so a future
// non-16kHz backend records at its native rate without further
// plumbing. The writer flushes its header every ~500 ms, so the
// file on disk is a playable WAV even if the process is killed.
let sample_rate = engine let sample_rate = engine
.capabilities() .capabilities()
.map(|c| c.sample_rate) .map(|c| c.sample_rate)
.unwrap_or(WHISPER_SAMPLE_RATE); .unwrap_or(WHISPER_SAMPLE_RATE);
let mut wav_writer: Option<WavWriter> = match audio_path.as_ref() { let path = audio_path?;
Some(path) => match WavWriter::create(path, sample_rate, 1) { match WavWriter::create(path, sample_rate, 1) {
Ok(w) => Some(w), Ok(writer) => Some(writer),
Err(e) => { Err(e) => {
let _ = status_channel.send(LiveStatusMessage::Warning { let _ = status_channel.send(LiveStatusMessage::Warning {
session_id, session_id,
@@ -398,165 +688,18 @@ fn run_live_session(
}); });
None None
} }
}, }
None => None, }
};
// `reported_audio_path` is decided at end-of-session based on
// whether the writer finalised successfully, not at open time.
// This way a writer that dies mid-session (append error clearing
// wav_writer, or finalise returning Err) does not leak a stale
// path back to the frontend that might point to a file whose
// header is out of sync with its data chunk.
let mut buffer_start_sample: u64 = 0; fn finalize_wav_writer(
let mut dropped_audio_ms: u64 = 0; wav_writer: Option<WavWriter>,
let mut chunk_id: u32 = 0; audio_path: Option<&PathBuf>,
let mut inflight: Option<InferenceTask> = None; session_id: u64,
let mut resampler_flushed = false; status_channel: &Channel<LiveStatusMessage>,
let mut recent_segments: Vec<RecentTranscriptSegment> = Vec::new(); ) -> Option<String> {
match wav_writer {
loop {
if let Some(_done) = poll_inference(
&mut inflight,
session_id,
&config,
&mut recent_segments,
&dictionary_terms,
&result_channel,
&status_channel,
)? {}
// Surface any cpal runtime errors as warnings. Non-fatal: a hard
// disconnect will also drop the audio sender and be caught by
// the `Disconnected` arm below. This lets the user see a toast
// even when cpal recovers without tearing the stream down.
if let Some(err_rx) = &mic_error_rx {
while let Ok(err) = err_rx.try_recv() {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
message: format!(
"Microphone '{}' reported an error: {}",
err.device_name, err.message
),
});
}
}
match rx.recv_timeout(Duration::from_millis(25)) {
Ok(chunk) => {
let mono = downmix_chunk(chunk.samples, chunk.channels as usize);
let resampler = match &mut resampler {
Some(resampler) => resampler,
None => {
resampler = Some(
StreamingResampler::new(chunk.sample_rate)
.map_err(|e| e.to_string())?,
);
resampler.as_mut().expect("resampler just set")
}
};
let resampled = resampler.push_samples(&mono).map_err(|e| e.to_string())?;
append_resampled_audio(
&mut capture_buffer,
&mut wav_writer,
&resampled,
session_id,
&status_channel,
);
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
let message = "Microphone capture disconnected unexpectedly".to_string();
let _ = status_channel.send(LiveStatusMessage::Error {
session_id,
message: message.clone(),
});
return Err(message);
}
}
if inflight.is_some() && capture_buffer.len() > MAX_PENDING_SAMPLES {
let overflow = capture_buffer.len() - MAX_PENDING_SAMPLES;
capture_buffer.drain(..overflow);
buffer_start_sample = buffer_start_sample.saturating_add(overflow as u64);
dropped_audio_ms = dropped_audio_ms
.saturating_add((overflow as u64 * 1000) / WHISPER_SAMPLE_RATE as u64);
let _ = status_channel.send(LiveStatusMessage::Overload {
session_id,
dropped_audio_ms,
message: "Kon dropped older audio to keep live dictation responsive".into(),
});
}
let stopping = stop_flag.load(Ordering::Relaxed);
if stopping && !resampler_flushed {
if let Some(resampler) = &mut resampler {
let tail = resampler.flush().map_err(|e| e.to_string())?;
append_resampled_audio(
&mut capture_buffer,
&mut wav_writer,
&tail,
session_id,
&status_channel,
);
}
resampler_flushed = true;
// Final flush for the WAV header so the last chunk's header
// update is on disk before we drop into the inference drain.
if let Some(writer) = wav_writer.as_mut() {
if let Err(e) = writer.flush() {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
message: format!("WAV flush failed near session end: {e}"),
});
}
}
}
if inflight.is_none() {
if let Some(task) = maybe_dispatch_chunk(
&engine,
&config,
&mut capture_buffer,
&mut buffer_start_sample,
&mut chunk_id,
stopping,
&status_channel,
session_id,
) {
inflight = Some(task);
continue;
}
if stopping && resampler_flushed {
break;
}
}
}
while inflight.is_some() {
poll_inference(
&mut inflight,
session_id,
&config,
&mut recent_segments,
&dictionary_terms,
&result_channel,
&status_channel,
)?;
thread::sleep(Duration::from_millis(10));
}
// Finalise the progressive WAV writer and decide whether to
// report a path to the frontend. Only a clean finalise produces a
// reported path: a writer that died mid-session (wav_writer was
// already None) or a finalise that itself errored both yield
// `None`, so `StopLiveTranscriptionResponse.audio_path` reflects
// "recording is known-good" rather than "recording was attempted".
let audio_path = match wav_writer.take() {
Some(writer) => match writer.finalize() { Some(writer) => match writer.finalize() {
Ok(()) => audio_path.as_ref().map(|p| p.to_string_lossy().to_string()), Ok(()) => audio_path.map(|path| path.to_string_lossy().to_string()),
Err(e) => { Err(e) => {
let _ = status_channel.send(LiveStatusMessage::Warning { let _ = status_channel.send(LiveStatusMessage::Warning {
session_id, session_id,
@@ -568,13 +711,7 @@ fn run_live_session(
} }
}, },
None => None, None => None,
}; }
Ok(LiveSessionSummary {
session_id,
dropped_audio_ms,
audio_path,
})
} }
fn append_resampled_audio( fn append_resampled_audio(
@@ -719,6 +856,7 @@ fn maybe_dispatch_chunk(
fn poll_inference( fn poll_inference(
inflight: &mut Option<InferenceTask>, inflight: &mut Option<InferenceTask>,
result_listener_lost: &mut bool,
session_id: u64, session_id: u64,
config: &StartLiveTranscriptionConfig, config: &StartLiveTranscriptionConfig,
recent_segments: &mut Vec<RecentTranscriptSegment>, recent_segments: &mut Vec<RecentTranscriptSegment>,
@@ -761,9 +899,7 @@ fn poll_inference(
); );
let segment_count = segments.len(); let segment_count = segments.len();
let delivered_segments = segments.clone(); let delivered_segments = segments.clone();
let result_message = LiveResultMessage {
result_channel
.send(LiveResultMessage {
session_id, session_id,
chunk_id: task.chunk_id, chunk_id: task.chunk_id,
chunk_start_secs, chunk_start_secs,
@@ -772,11 +908,21 @@ fn poll_inference(
inference_ms: timed.inference_ms, inference_ms: timed.inference_ms,
segments, segments,
raw_text, raw_text,
}) };
.map_err(|e| e.to_string())?; let delivered = emit_live_result(
result_channel,
status_channel,
result_listener_lost,
&result_message,
);
remember_recent_segments(recent_segments, &delivered_segments, chunk_start_secs); remember_recent_segments(recent_segments, &delivered_segments, chunk_start_secs);
eprintln!( eprintln!(
"[live] session {session_id}: delivered chunk {} with {} segments in {}ms{}", "[live] session {session_id}: {} chunk {} with {} segments in {}ms{}",
if delivered {
"delivered"
} else {
"processed without listener for"
},
task.chunk_id, task.chunk_id,
segment_count, segment_count,
timed.inference_ms, timed.inference_ms,
@@ -813,6 +959,33 @@ fn poll_inference(
} }
} }
fn emit_live_result(
result_channel: &Channel<LiveResultMessage>,
status_channel: &Channel<LiveStatusMessage>,
result_listener_lost: &mut bool,
result_message: &LiveResultMessage,
) -> bool {
if *result_listener_lost {
return false;
}
match result_channel.send(result_message.clone()) {
Ok(()) => true,
Err(err) => {
*result_listener_lost = true;
eprintln!(
"[live] session {}: result listener unavailable on chunk {}: {}; continuing without live updates",
result_message.session_id, result_message.chunk_id, err
);
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id: result_message.session_id,
message: "Live preview disconnected; transcription will continue in the background until you stop the session.".into(),
});
false
}
}
}
fn trim_overlap_segments(segments: &mut Vec<Segment>, trim_before_secs: f64) { fn trim_overlap_segments(segments: &mut Vec<Segment>, trim_before_secs: f64) {
if trim_before_secs <= 0.0 { if trim_before_secs <= 0.0 {
return; return;
@@ -1150,6 +1323,82 @@ fn downmix_chunk(samples: Vec<f32>, channels: usize) -> Vec<f32> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tauri::ipc::InvokeResponseBody;
fn noop_status_channel() -> Channel<LiveStatusMessage> {
Channel::new(|_| Ok(()))
}
fn collecting_status_channel(payloads: Arc<Mutex<Vec<String>>>) -> Channel<LiveStatusMessage> {
Channel::new(move |body| {
if let InvokeResponseBody::Json(json) = body {
payloads.lock().unwrap().push(json);
}
Ok(())
})
}
fn dummy_running_session(
id: u64,
release_join: Option<Arc<tokio::sync::Notify>>,
) -> RunningLiveSession {
let stop_flag = Arc::new(AtomicBool::new(false));
let handle = tokio::spawn(async move {
if let Some(notify) = release_join {
notify.notified().await;
}
Ok(LiveSessionSummary {
session_id: id,
dropped_audio_ms: 0,
audio_path: None,
})
});
RunningLiveSession {
id,
stop_flag,
handle,
status_channel: noop_status_channel(),
}
}
async fn test_begin_session_start(
live_state: Arc<LiveTranscriptionState>,
session_id: u64,
release_setup: Option<Arc<tokio::sync::Notify>>,
) -> Result<u64, String> {
let _lifecycle = live_state.lifecycle.lock().await;
{
let running = live_state.running.lock().unwrap();
if running.is_some() {
return Err("A live transcription session is already running".into());
}
}
if let Some(notify) = release_setup {
notify.notified().await;
}
*live_state.running.lock().unwrap() = Some(dummy_running_session(session_id, None));
Ok(session_id)
}
async fn test_stop_session(
live_state: Arc<LiveTranscriptionState>,
session_id: u64,
) -> Result<LiveSessionSummary, String> {
let _lifecycle = live_state.lifecycle.lock().await;
let running = live_state.running.lock().unwrap().take();
let Some(running) = running else {
return Err("No live transcription session is running".into());
};
if running.id != session_id {
*live_state.running.lock().unwrap() = Some(running);
return Err(format!("Session {session_id} is not active"));
}
running.stop_flag.store(true, Ordering::Relaxed);
running
.handle
.await
.map_err(|e| format!("Live session task failed: {e}"))?
}
fn segment(start: f64, end: f64, text: &str) -> Segment { fn segment(start: f64, end: f64, text: &str) -> Segment {
Segment { Segment {
@@ -1256,4 +1505,157 @@ mod tests {
assert_eq!(decision.speech_window_count, 3); assert_eq!(decision.speech_window_count, 3);
assert_eq!(decision.max_consecutive_speech_windows, 3); assert_eq!(decision.max_consecutive_speech_windows, 3);
} }
#[test]
fn result_listener_loss_is_warned_once_and_not_treated_as_inference_failure() {
let statuses = Arc::new(Mutex::new(Vec::new()));
let status_channel = collecting_status_channel(statuses.clone());
let result_channel = Channel::new(|_| Err(tauri::Error::FailedToReceiveMessage));
let config = StartLiveTranscriptionConfig {
engine: "whisper".into(),
model_id: None,
language: Some("en".into()),
initial_prompt: None,
save_audio: false,
output_folder: None,
remove_fillers: false,
british_english: false,
anti_hallucination: false,
format_mode: "Raw".into(),
microphone_device: None,
profile_id: None,
};
let mut recent_segments = Vec::new();
let mut result_listener_lost = false;
let (tx1, rx1) = std::sync::mpsc::channel();
tx1.send(Ok(kon_transcription::TimedTranscript {
transcript: kon_core::types::Transcript::new(
vec![segment(0.0, 0.8, "first chunk")],
"en".into(),
0.8,
),
inference_ms: 12,
}))
.unwrap();
let mut inflight = Some(InferenceTask {
chunk_id: 1,
chunk_start_sample: 0,
trim_before_secs: 0.0,
duration_secs: 0.8,
rx: rx1,
});
let first = poll_inference(
&mut inflight,
&mut result_listener_lost,
77,
&config,
&mut recent_segments,
&[],
&result_channel,
&status_channel,
)
.unwrap();
assert_eq!(first, Some(true));
assert!(result_listener_lost);
assert!(inflight.is_none());
assert_eq!(recent_segments.len(), 1);
let warning_count_after_first = statuses.lock().unwrap().len();
assert_eq!(warning_count_after_first, 1);
assert!(
statuses.lock().unwrap()[0].contains("Live preview disconnected"),
"expected a warning about background continuation after listener loss"
);
let (tx2, rx2) = std::sync::mpsc::channel();
tx2.send(Ok(kon_transcription::TimedTranscript {
transcript: kon_core::types::Transcript::new(
vec![segment(0.0, 0.9, "second chunk")],
"en".into(),
0.9,
),
inference_ms: 14,
}))
.unwrap();
inflight = Some(InferenceTask {
chunk_id: 2,
chunk_start_sample: 16_000,
trim_before_secs: 0.0,
duration_secs: 0.9,
rx: rx2,
});
let second = poll_inference(
&mut inflight,
&mut result_listener_lost,
77,
&config,
&mut recent_segments,
&[],
&result_channel,
&status_channel,
)
.unwrap();
assert_eq!(second, Some(true));
assert!(inflight.is_none());
assert_eq!(recent_segments.len(), 2);
assert_eq!(
statuses.lock().unwrap().len(),
warning_count_after_first,
"listener-loss warning should only be emitted once"
);
}
#[tokio::test]
async fn concurrent_starts_allow_only_one_session_to_claim_the_slot() {
let live_state = Arc::new(LiveTranscriptionState::default());
let release_setup = Arc::new(tokio::sync::Notify::new());
let first = tokio::spawn(test_begin_session_start(
live_state.clone(),
1,
Some(release_setup.clone()),
));
tokio::time::sleep(Duration::from_millis(20)).await;
let second = tokio::spawn(test_begin_session_start(live_state.clone(), 2, None));
tokio::time::sleep(Duration::from_millis(20)).await;
assert!(
!second.is_finished(),
"second start should wait on the lifecycle lock"
);
release_setup.notify_one();
assert_eq!(first.await.unwrap().unwrap(), 1);
let err = second.await.unwrap().unwrap_err();
assert_eq!(err, "A live transcription session is already running");
}
#[tokio::test]
async fn start_waits_for_stop_to_finish_joining_before_reusing_slot() {
let live_state = Arc::new(LiveTranscriptionState::default());
let release_join = Arc::new(tokio::sync::Notify::new());
*live_state.running.lock().unwrap() =
Some(dummy_running_session(7, Some(release_join.clone())));
let stop = tokio::spawn(test_stop_session(live_state.clone(), 7));
tokio::time::sleep(Duration::from_millis(20)).await;
let start = tokio::spawn(test_begin_session_start(live_state.clone(), 8, None));
tokio::time::sleep(Duration::from_millis(20)).await;
assert!(
!start.is_finished(),
"new start should block until stop finishes joining the old worker"
);
release_join.notify_one();
let summary = stop.await.unwrap().unwrap();
assert_eq!(summary.session_id, 7);
assert_eq!(start.await.unwrap().unwrap(), 8);
}
} }

View File

@@ -8,10 +8,10 @@ use kon_core::constants::WHISPER_SAMPLE_RATE;
use kon_core::hardware::{self, CpuFeatures}; use kon_core::hardware::{self, CpuFeatures};
use kon_core::model_registry::{self, Engine, LanguageSupport, ModelEntry}; use kon_core::model_registry::{self, Engine, LanguageSupport, ModelEntry};
use kon_core::types::{AudioSamples, ModelId, TranscriptionOptions}; use kon_core::types::{AudioSamples, ModelId, TranscriptionOptions};
use kon_transcription::model_manager;
use kon_transcription::{load_parakeet, LocalEngine, Transcriber};
#[cfg(feature = "whisper")] #[cfg(feature = "whisper")]
use kon_transcription::load_whisper; use kon_transcription::load_whisper;
use kon_transcription::model_manager;
use kon_transcription::{load_parakeet, LocalEngine, Transcriber};
/// Map legacy size strings to ModelId. /// Map legacy size strings to ModelId.
fn whisper_model_id(size: &str) -> ModelId { fn whisper_model_id(size: &str) -> ModelId {
@@ -73,9 +73,7 @@ fn model_capability(
} }
} }
pub fn load_model_from_disk( pub fn load_model_from_disk(model_id: &ModelId) -> Result<Box<dyn Transcriber + Send>, String> {
model_id: &ModelId,
) -> Result<Box<dyn Transcriber + Send>, String> {
let entry = let entry =
model_registry::find_model(model_id).ok_or_else(|| format!("Unknown model: {model_id}"))?; model_registry::find_model(model_id).ok_or_else(|| format!("Unknown model: {model_id}"))?;
@@ -205,8 +203,7 @@ pub fn prewarm_default_model(whisper_engine: Arc<LocalEngine>) {
// latency instead of the ~45s cold-start documented in // latency instead of the ~45s cold-start documented in
// ufal/whisper_streaming #96 and #135. Silence returns // ufal/whisper_streaming #96 and #135. Silence returns
// empty segments — the *work* is the context allocation. // empty segments — the *work* is the context allocation.
let silence = let silence = AudioSamples::mono_16khz(vec![0.0_f32; WHISPER_SAMPLE_RATE as usize]);
AudioSamples::mono_16khz(vec![0.0_f32; WHISPER_SAMPLE_RATE as usize]);
let options = TranscriptionOptions::default(); let options = TranscriptionOptions::default();
match whisper_engine.transcribe_sync(&silence, &options) { match whisper_engine.transcribe_sync(&silence, &options) {
Ok(_) => eprintln!("[startup] Whisper warm-up inference complete"), Ok(_) => eprintln!("[startup] Whisper warm-up inference complete"),
@@ -379,11 +376,7 @@ fn supported_accelerators() -> Vec<String> {
} else { } else {
AcceleratorTarget::Other AcceleratorTarget::Other
}; };
compose_accelerators( compose_accelerators(cfg!(feature = "whisper"), vulkan_loader_available(), target)
cfg!(feature = "whisper"),
vulkan_loader_available(),
target,
)
} }
/// Report which backend whisper.cpp was actually able to initialise /// Report which backend whisper.cpp was actually able to initialise
@@ -405,8 +398,7 @@ pub fn detect_active_compute_device() -> ActiveComputeDevice {
kind: "cpu".into(), kind: "cpu".into(),
label: "CPU (fallback)".into(), label: "CPU (fallback)".into(),
reason: Some( reason: Some(
"MoltenVK / Vulkan loader not found — install the Vulkan SDK runtime." "MoltenVK / Vulkan loader not found — install the Vulkan SDK runtime.".into(),
.into(),
), ),
}; };
} }

View File

@@ -396,7 +396,11 @@ fn detect_focused_window_class_macos() -> Option<String> {
return None; return None;
} }
let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() { None } else { Some(name) } if name.is_empty() {
None
} else {
Some(name)
}
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -421,7 +425,11 @@ fn detect_focused_window_class_windows() -> Option<String> {
return None; return None;
} }
let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() { None } else { Some(name) } if name.is_empty() {
None
} else {
Some(name)
}
} }
fn trigger_paste_keystroke() -> Result<String, String> { fn trigger_paste_keystroke() -> Result<String, String> {
@@ -473,7 +481,10 @@ fn trigger_undo_keystroke() -> Result<String, String> {
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn linux_paste(xdg_session_type: Option<&str>, wayland_display_set: bool) -> Result<String, String> { fn linux_paste(
xdg_session_type: Option<&str>,
wayland_display_set: bool,
) -> Result<String, String> {
for tool in pick_linux_backend_order(xdg_session_type, wayland_display_set) { for tool in pick_linux_backend_order(xdg_session_type, wayland_display_set) {
match run_linux_tool(tool) { match run_linux_tool(tool) {
Ok(()) => return Ok(tool.to_string()), Ok(()) => return Ok(tool.to_string()),

View File

@@ -21,7 +21,17 @@
//! may still decide to idle us. We log when that happens so the //! may still decide to idle us. We log when that happens so the
//! diagnostics bundle has a breadcrumb. //! diagnostics bundle has a breadcrumb.
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};
#[derive(Debug, Clone)]
pub struct PowerAssertionSnapshot {
pub id: usize,
pub reason: &'static str,
pub backend: &'static str,
pub acquired: bool,
}
/// Handle for a single power assertion. Dropping it releases the /// Handle for a single power assertion. Dropping it releases the
/// assertion. Holders are expected to keep it alive in a field for /// assertion. Holders are expected to keep it alive in a field for
@@ -32,12 +42,30 @@ pub struct PowerAssertion {
#[allow(dead_code)] #[allow(dead_code)]
id: usize, id: usize,
reason: &'static str, reason: &'static str,
backend: &'static str,
acquired: bool,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
activity: Option<objc_bridge::ActivityHandle>, activity: Option<objc_bridge::ActivityHandle>,
} }
static NEXT_ID: AtomicUsize = AtomicUsize::new(1); static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
fn assertion_registry() -> &'static Mutex<HashMap<usize, PowerAssertionSnapshot>> {
static REGISTRY: OnceLock<Mutex<HashMap<usize, PowerAssertionSnapshot>>> = OnceLock::new();
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn active_assertions_snapshot() -> Vec<PowerAssertionSnapshot> {
let mut snapshots = assertion_registry()
.lock()
.unwrap()
.values()
.cloned()
.collect::<Vec<_>>();
snapshots.sort_by_key(|snapshot| snapshot.id);
snapshots
}
impl PowerAssertion { impl PowerAssertion {
/// Begin a power assertion for the given reason. On macOS this /// Begin a power assertion for the given reason. On macOS this
/// pins beginActivityWithOptions; on Linux/Windows it logs only /// pins beginActivityWithOptions; on Linux/Windows it logs only
@@ -47,12 +75,16 @@ impl PowerAssertion {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let activity = objc_bridge::begin_activity(reason).ok(); let activity = objc_bridge::begin_activity(reason).ok();
#[cfg(target_os = "macos")]
let backend = "macos";
#[cfg(target_os = "macos")]
let acquired = activity.is_some();
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if activity.is_none() { if acquired {
eprintln!( eprintln!("[power] began macOS App Nap guard #{id} for reason '{reason}'");
"[power] macOS App Nap guard could not begin activity for reason '{reason}'" } else {
); eprintln!("[power] macOS App Nap guard could not begin activity for reason '{reason}'");
} }
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
@@ -63,9 +95,26 @@ impl PowerAssertion {
let _ = reason; let _ = reason;
} }
#[cfg(not(target_os = "macos"))]
let backend = "noop";
#[cfg(not(target_os = "macos"))]
let acquired = false;
assertion_registry().lock().unwrap().insert(
id,
PowerAssertionSnapshot {
id,
reason,
backend,
acquired,
},
);
Self { Self {
id, id,
reason, reason,
backend,
acquired,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
activity, activity,
} }
@@ -77,64 +126,83 @@ impl Drop for PowerAssertion {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if let Some(handle) = self.activity.take() { if let Some(handle) = self.activity.take() {
objc_bridge::end_activity(handle); objc_bridge::end_activity(handle);
eprintln!(
"[power] ended macOS App Nap guard #{} for reason '{}'",
self.id, self.reason
);
} }
assertion_registry().lock().unwrap().remove(&self.id);
let _ = (self.reason, self.id); let _ = (self.reason, self.id);
let _ = (self.backend, self.acquired);
} }
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod objc_bridge { mod objc_bridge {
//! Placeholder for the NSProcessInfo App-Nap bridge. use objc2::rc::Retained;
//! use objc2::runtime::ProtocolObject;
//! A proper implementation calls: use objc2_foundation::{NSActivityOptions, NSObjectProtocol, NSProcessInfo, NSString};
//! `NSProcessInfo *info = [NSProcessInfo processInfo];`
//! `id activity = [info beginActivityWithOptions:
//! (NSActivityUserInitiated | NSActivityLatencyCritical)
//! reason:reasonNSString];`
//! and retains the returned object until `end_activity`.
//!
//! This workstream ships the PowerAssertion RAII guard + wiring
//! so `commands/live.rs` and `commands/llm.rs` can adopt it today
//! (matters on macOS, no-op elsewhere). The actual `objc2` bridge
//! lands in a follow-up commit that can introduce `objc2` +
//! `objc2-foundation` without touching the rest of the workspace
//! in the same change.
//!
//! Until then, `begin_activity` returns Err; callers (`begin()`)
//! log a warning but keep running, so recording continues to work
//! as today — the gap is just the App-Nap protection, not the
//! recording itself.
pub struct ActivityHandle { pub struct ActivityHandle {
#[allow(dead_code)] activity: Retained<ProtocolObject<dyn NSObjectProtocol>>,
retained: *mut std::ffi::c_void,
} }
// SAFETY: The pointer is opaque to Rust; Foundation manages its
// lifetime via retain/release. We never dereference it directly.
unsafe impl Send for ActivityHandle {} unsafe impl Send for ActivityHandle {}
pub fn begin_activity(_reason: &str) -> Result<ActivityHandle, String> { pub fn begin_activity(reason: &str) -> Result<ActivityHandle, String> {
Err("macOS App Nap bridge not yet wired — objc2 integration tracked for a follow-up".into()) let process_info = NSProcessInfo::processInfo();
let reason = NSString::from_str(reason);
let options = NSActivityOptions::UserInitiated | NSActivityOptions::LatencyCritical;
let activity = process_info.beginActivityWithOptions_reason(options, &reason);
Ok(ActivityHandle { activity })
} }
pub fn end_activity(_handle: ActivityHandle) {} pub fn end_activity(handle: ActivityHandle) {
let process_info = NSProcessInfo::processInfo();
unsafe {
process_info.endActivity(&handle.activity);
}
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::{Mutex, MutexGuard, OnceLock};
fn power_test_guard() -> MutexGuard<'static, ()> {
static TEST_GUARD: OnceLock<Mutex<()>> = OnceLock::new();
TEST_GUARD.get_or_init(|| Mutex::new(())).lock().unwrap()
}
fn clear_assertion_registry() {
assertion_registry().lock().unwrap().clear();
}
#[test] #[test]
fn power_assertion_is_a_no_op_drop() { fn power_assertion_is_a_no_op_drop() {
let _guard = power_test_guard();
clear_assertion_registry();
let guard = PowerAssertion::begin("test-reason"); let guard = PowerAssertion::begin("test-reason");
let snapshots = active_assertions_snapshot();
assert!(snapshots.iter().any(|snapshot| snapshot.id == guard.id));
drop(guard); drop(guard);
assert!(active_assertions_snapshot().is_empty());
} }
#[test] #[test]
fn multiple_assertions_get_unique_ids() { fn multiple_assertions_get_unique_ids() {
let _guard = power_test_guard();
clear_assertion_registry();
let a = PowerAssertion::begin("a"); let a = PowerAssertion::begin("a");
let b = PowerAssertion::begin("b"); let b = PowerAssertion::begin("b");
assert_ne!(a.id, b.id); assert_ne!(a.id, b.id);
let snapshots = active_assertions_snapshot();
assert_eq!(snapshots.len(), 2);
assert_eq!(snapshots[0].reason, "a");
assert_eq!(snapshots[1].reason, "b");
drop(a);
drop(b);
assert!(active_assertions_snapshot().is_empty());
} }
} }

View File

@@ -74,8 +74,10 @@ async fn save_preferences(
/// known crashes that the HANDOVER documents working around with a manual /// known crashes that the HANDOVER documents working around with a manual
/// env-var prefix: /// env-var prefix:
/// ///
/// ```sh
/// env GDK_BACKEND=x11 WINIT_UNIX_BACKEND=x11 \ /// env GDK_BACKEND=x11 WINIT_UNIX_BACKEND=x11 \
/// WEBKIT_DISABLE_DMABUF_RENDERER=1 npm run tauri dev /// WEBKIT_DISABLE_DMABUF_RENDERER=1 npm run tauri dev
/// ```
/// ///
/// Detect the Wayland session at startup and apply the env vars before /// Detect the Wayland session at startup and apply the env vars before
/// anything else loads, so users do not need to remember the prefix and /// anything else loads, so users do not need to remember the prefix and