Files
WhispAssist/src-tauri/src/commands.rs
T

3729 lines
134 KiB
Rust

//! Tauri command handlers — the frontend's only entry into the core.
//! Contract: `docs/04-api-contracts.md`. Commands return promptly; long work
//! is spawned and reported via events ("recording://*", "transcript://*", …).
//!
//! Phases 1-5 commands are real implementations; everything for a
//! not-yet-reached phase returns `Err(not_implemented(...))` rather than
//! panicking — `panic = "abort"` in the release profile means any panic
//! reachable from a command handler kills the whole app, not just that call.
use crate::audio::{AudioCapture, WasapiCapture};
use crate::calendar::{CalImport, CalendarSource, PstSource};
use crate::diarization::{Diarizer, SherpaDiarizer};
use crate::error::WaResult;
use crate::hardware::{HardwareDetector, WinHardwareDetector};
use crate::models::*;
use crate::notes::NotesRenderer;
use crate::paths::{
diarization_embedding_model_file, diarization_segmentation_model_file, meeting_dir,
settings_path, wa_root, whisper_model_file,
};
use crate::storage::{FinalizeMeeting, Meeting, NewMeeting, SummaryFile, SyncTargetRow};
use crate::transcription::{
models as model_catalog, run_streaming_worker, Transcriber, WhisperTranscriber,
};
use crate::{error::WaError, AppState, RecordingSession};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex};
use tauri::{AppHandle, Emitter, Manager, State};
#[derive(Deserialize)]
pub struct StartRecordingArgs {
pub meeting_title: Option<String>,
pub calendar_event_id: Option<String>,
/// Retain audio as .wav? Defaults to false (ADR-0009). Controls retention, not capture.
#[serde(default)]
pub record: bool,
/// Note-template id (Phase 8, T8.1, FR-NOTE-5) — see `notes::built_in_note_templates`.
pub template_id: Option<String>,
}
// ---- File-backed settings (consent/default-retention/storage policy) ----
// `settings.json` per `docs/03-data-model.md`; meeting rows/files go through
// `AppState.store` (Phase 2, `storage::SqliteStore`).
fn default_settings() -> Settings {
Settings {
theme: "system".into(),
storage_root: wa_root().display().to_string(),
llm_provider: "ollama".into(),
llm_endpoint: "http://localhost:11434".into(),
llm_model: "llama3".into(),
llm_advanced: serde_json::Value::Null,
preferred_backend: "auto".into(),
whisper_model: crate::paths::DEFAULT_WHISPER_MODEL.to_string(),
low_overhead: false,
default_record: false,
consent_acknowledged: false,
sync_enabled: false,
retention_max_age_days: None,
retention_max_size_gb: None,
pst_last_path: None,
pst_auto_sync: false,
audio_output_device: None,
microphone_enabled: true,
audio_input_device: None,
}
}
pub(crate) fn load_settings() -> Settings {
std::fs::read_to_string(settings_path())
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(default_settings)
}
fn save_settings(settings: &Settings) -> Result<(), WaError> {
let path = settings_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| WaError::new("settings", e.to_string()))?;
}
let json = serde_json::to_string_pretty(settings)
.map_err(|e| WaError::new("settings", e.to_string()))?;
std::fs::write(path, json).map_err(|e| WaError::new("settings", e.to_string()))
}
/// Resolves which model file a recording should use: the "low overhead" preset
/// (T3.9) always takes the smallest catalog model over whatever is configured,
/// since it's optimizing for overhead, not accuracy.
fn model_id_for(settings: &Settings) -> String {
if settings.low_overhead {
model_catalog::smallest_id().to_string()
} else {
settings.whisper_model.clone()
}
}
/// Picks the backend to transcribe with: "low overhead" always forces CPU;
/// otherwise resolve the user's preferred backend (or auto-detect) against
/// what's actually available (T3.2, T3.5).
fn backend_for(settings: &Settings) -> BackendId {
if settings.low_overhead {
return BackendId::Cpu;
}
let preferred = match settings.preferred_backend.as_str() {
"npu" => Some(BackendId::Npu),
"nvidia" => Some(BackendId::Nvidia),
"amd" => Some(BackendId::Amd),
"intel" => Some(BackendId::Intel),
"cpu" => Some(BackendId::Cpu),
_ => None, // "auto"
};
WinHardwareDetector.best(preferred).id
}
/// Builds the transcriber for `backend`: the NPU backend goes to the
/// ONNX/OpenVINO engine (T3.4), everything else to whisper.cpp. Falls through to
/// whisper.cpp on CPU if the chosen engine can't load, and returns the backend
/// *actually* used so callers can update their "active backend" state (T3.5,
/// FR-HW-4). One place so streaming and batch dispatch identically.
#[cfg(feature = "cpu-transcription")]
fn load_transcriber(
backend: BackendId,
whisper_model: &Path,
) -> Result<(Box<dyn Transcriber>, BackendId), crate::transcription::TrxError> {
use crate::hardware::{resolve_accel, AccelPath};
// Resolve how this backend is actually served in *this* build (CUDA/Vulkan
// baked in? NPU/DirectML runtime present?) rather than assuming a GPU
// backend has a working accel path just because the hardware exists.
let path = resolve_accel(backend);
// ONNX engine — NPU (OpenVINO EP) or AMD/Intel GPU (DirectML EP). Same model
// artifacts either way; the EP is chosen inside OnnxTranscriber::load from
// `backend`, and the resolved `backend` is reported so the UI shows the real
// engine.
#[cfg(feature = "npu")]
if matches!(path, AccelPath::OnnxOpenVino | AccelPath::OnnxDirectML) {
use crate::transcription::{onnx_models, OnnxTranscriber};
if onnx_models::is_installed(onnx_models::DEFAULT_ONNX_MODEL) {
let dir = onnx_models::model_dir(onnx_models::DEFAULT_ONNX_MODEL);
match OnnxTranscriber::load(&dir, backend) {
Ok(t) => return Ok((Box::new(t), backend)),
Err(e) => tracing::warn!("ONNX engine load failed ({e}); falling back to CPU"),
}
} else {
tracing::warn!(
"ONNX backend selected but model not installed; falling back to whisper.cpp"
);
}
}
// whisper.cpp path: only ask for GPU offload when the resolver picked a
// whisper GPU backend that's compiled in — otherwise a bogus `use_gpu` for a
// vendor with no accel path just no-ops. Anything else decodes on the CPU.
let whisper_backend = match path {
AccelPath::WhisperCuda | AccelPath::WhisperVulkan => backend,
_ => BackendId::Cpu,
};
match WhisperTranscriber::load(whisper_model, whisper_backend) {
Ok(t) => Ok((Box::new(t), whisper_backend)),
Err(e) if whisper_backend != BackendId::Cpu => {
tracing::warn!("backend {whisper_backend:?} failed to load ({e}); falling back to CPU");
WhisperTranscriber::load(whisper_model, BackendId::Cpu)
.map(|t| (Box::new(t) as Box<dyn Transcriber>, BackendId::Cpu))
}
Err(e) => Err(e),
}
}
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// A command whose feature isn't built yet (roadmap phase not reached). This
/// is deliberately a returned `Err`, never a `todo!()`/`unimplemented!()`:
/// the release profile sets `panic = "abort"`, so a panic anywhere a command
/// handler can reach would kill the whole app the instant any frontend code
/// path calls it — not just fail that one call (found the hard way: an
/// unconditional `listSyncTargets()` on startup was crashing every launch).
fn not_implemented(feature: &str) -> WaError {
WaError::new("not_implemented", format!("{feature} isn't built yet"))
}
/// Builds a `Diarizer` if both diarization models are installed — a fixed
/// pair of well-known filenames, downloadable/removable via
/// `diarization::models` and `list_diarization_models`/`download_model`/
/// `remove_model` (T4.7). Returns `None` rather than erring — diarization is
/// a provisional/refinement layer that a recording never depends on, same
/// treatment as a missing hardware backend.
fn diarizer_from_installed_models() -> Option<SherpaDiarizer> {
let seg_model = diarization_segmentation_model_file();
let emb_model = diarization_embedding_model_file();
if !seg_model.exists() || !emb_model.exists() {
return None;
}
match SherpaDiarizer::new(&seg_model, &emb_model) {
Ok(d) => Some(d),
Err(e) => {
tracing::warn!("diarization models present but failed to load: {e}");
None
}
}
}
/// The distinct speakers seen in `segments` so far, in first-appearance order,
/// with any display names applied (T4.3/T4.4, FR-SPK-2/5). Falls back to the
/// single pre-diarization "S1" placeholder if no segments exist yet.
fn speaker_infos_from_segments(
segments: &[TranscriptSegment],
names: &HashMap<String, String>,
) -> Vec<SpeakerInfo> {
let mut seen = std::collections::HashSet::new();
let mut out: Vec<SpeakerInfo> = segments
.iter()
.filter(|s| seen.insert(s.speaker.clone()))
.map(|s| SpeakerInfo {
label: s.speaker.clone(),
display_name: names.get(&s.speaker).cloned(),
participant_id: None,
})
.collect();
if out.is_empty() {
out.push(SpeakerInfo {
label: "S1".to_string(),
display_name: names.get("S1").cloned(),
participant_id: None,
});
}
out
}
// ---- Recording lifecycle (Phase 1) ----
#[tauri::command]
pub async fn start_recording(
app: AppHandle,
state: State<'_, AppState>,
args: StartRecordingArgs,
) -> WaResult<MeetingId> {
let mut guard = state.session.lock().await;
if guard.is_some() {
return Err(WaError::new(
"recording",
"a meeting is already in progress",
));
}
if args.record && !load_settings().consent_acknowledged {
return Err(WaError::new(
"consent",
"recording consent has not been acknowledged yet",
));
}
let settings = load_settings();
let model_id = model_id_for(&settings);
let backend = backend_for(&settings);
let model_path = whisper_model_file(&model_id);
if !model_path.exists() {
return Err(WaError::new(
"transcription",
format!(
"whisper model '{model_id}' not found at {}; download it from Settings first",
model_path.display()
),
));
}
let meeting_id = state
.store
.create_meeting(NewMeeting {
title: args
.meeting_title
.unwrap_or_else(|| "Untitled meeting".to_string()),
calendar_event_id: args.calendar_event_id,
template_id: args.template_id,
})
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let wav_path = meeting_dir(&meeting_id).join("audio.wav");
// Bounded so a slow/stalled transcription worker can never back up the capture thread.
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
// Level updates + device-change notices (FR-CAP-5/6); low-volume, forwarded to events below.
let (event_tx, event_rx) = std::sync::mpsc::sync_channel::<crate::audio::CaptureEvent>(8);
// When the mic is enabled (FR-CAP-7), capture it alongside loopback and let
// the mixer sum both into `frame_tx`; with it off, loopback feeds the
// transcription worker directly, exactly as before (no mixer overhead). A
// microphone that fails to open must not sink the meeting: we log and fall
// back to loopback-only (the mixer forwards loopback alone once its sink
// drops).
let (capture, mic_capture) = if settings.microphone_enabled {
// The mixer sums both streams for the live transcript; the bridge carries
// the mic into the loopback thread so the recorded WAV holds both sides
// at native quality (FR-CAP-7).
let bridge = crate::audio::MicBridge::shared();
let (loop_sink, mic_sink) = crate::audio::spawn_mixer(frame_tx);
let capture = WasapiCapture
.start_loopback_recording(
&wav_path,
settings.audio_output_device.as_deref(),
loop_sink,
event_tx.clone(),
bridge.clone(),
)
.map_err(|e| WaError::new("audio", e.to_string()))?;
let mic = WasapiCapture
.start_microphone_recording(
settings.audio_input_device.as_deref(),
mic_sink,
event_tx,
bridge,
)
.map_err(|e| tracing::warn!("microphone capture unavailable: {e}"))
.ok();
(capture, mic)
} else {
let capture = WasapiCapture
.start(
&wav_path,
settings.audio_output_device.as_deref(),
frame_tx,
event_tx,
)
.map_err(|e| WaError::new("audio", e.to_string()))?;
(capture, None)
};
// Fire-and-forget: exits on its own once `event_tx` drops at capture stop;
// nothing downstream needs to join it.
let app_for_capture_events = app.clone();
let meeting_id_for_capture_events = meeting_id.clone();
std::thread::Builder::new()
.name("wa-capture-events".into())
.spawn(move || {
for event in event_rx {
match event {
crate::audio::CaptureEvent::Level(level) => {
let _ = app_for_capture_events.emit(
"recording://level",
serde_json::json!({
"meetingId": meeting_id_for_capture_events,
"rms": level.rms,
"peak": level.peak,
}),
);
}
crate::audio::CaptureEvent::DeviceChanged { recovered, message } => {
let _ = app_for_capture_events.emit(
"recording://device",
serde_json::json!({
"meetingId": meeting_id_for_capture_events,
"recovered": recovered,
"message": message,
}),
);
}
}
}
})
.map_err(|e| WaError::new("audio", e.to_string()))?;
let segments: Arc<StdMutex<Vec<TranscriptSegment>>> = Arc::new(StdMutex::new(Vec::new()));
let segments_for_worker = segments.clone();
let active_backend: Arc<StdMutex<BackendId>> = Arc::new(StdMutex::new(backend));
let active_backend_for_worker = active_backend.clone();
let app_for_worker = app.clone();
let meeting_id_for_worker = meeting_id.clone();
let transcription_worker = std::thread::Builder::new()
.name("wa-transcription".into())
.spawn(move || {
// Dispatch to the NPU engine or whisper.cpp, with graceful CPU
// fall-through (T3.4/T3.5, FR-HW-4): a GPU/NPU load failure (driver
// issue, OOM, missing model) drops to CPU rather than losing the
// meeting's transcript entirely.
let (transcriber, used) = match load_transcriber(backend, &model_path) {
Ok(pair) => pair,
Err(e) => {
tracing::error!("failed to load any transcriber: {e}");
return;
}
};
if used != backend {
if let Ok(mut b) = active_backend_for_worker.lock() {
*b = used;
}
let _ = app_for_worker.emit(
"hardware://changed",
serde_json::json!({
"active": used.as_str(),
"reason": format!("{backend:?} unavailable; using {used:?}"),
}),
);
}
run_streaming_worker(transcriber.as_ref(), frame_rx, |segment| {
if let Ok(mut buf) = segments_for_worker.lock() {
buf.push(segment.clone());
}
let _ = app_for_worker.emit(
"transcript://segment",
serde_json::json!({ "meetingId": meeting_id_for_worker, "segment": segment }),
);
});
})
.map_err(|e| WaError::new("transcription", e.to_string()))?;
// T4.3: cheap/provisional live diarization, skipped entirely (None) when
// the diarization models aren't installed yet (T4.7) — never blocks
// recording, same graceful-degradation treatment as a missing backend.
// Loading the ONNX models is blocking I/O, so it runs off this async task.
let diarizer: Option<Arc<dyn Diarizer>> =
tauri::async_runtime::spawn_blocking(diarizer_from_installed_models)
.await
.ok()
.flatten()
.map(|d| Arc::new(d) as Arc<dyn Diarizer>);
let speaker_names: Arc<StdMutex<HashMap<String, String>>> =
Arc::new(StdMutex::new(HashMap::new()));
if let Some(diarizer) = diarizer.clone() {
let app_for_diar = app.clone();
let meeting_id_for_diar = meeting_id.clone();
let wav_path_for_diar = wav_path.clone();
let segments_for_diar = segments.clone();
let names_for_diar = speaker_names.clone();
tauri::async_runtime::spawn(async move {
// ponytail: reprocesses the whole recording-so-far each tick
// rather than incremental/windowed segmentation — sherpa-onnx's
// offline Diarizer has no streaming primitive to build on, and
// this is provisional preview only (the accurate pass runs once
// at stop). Fine at meeting length and a 15s cadence; revisit
// with real streaming segmentation if long meetings make it heavy.
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(15));
ticker.tick().await; // interval's first tick fires immediately; skip it
loop {
ticker.tick().await;
let still_active = app_for_diar
.state::<AppState>()
.session
.lock()
.await
.as_ref()
.is_some_and(|s| s.meeting_id == meeting_id_for_diar);
if !still_active {
break; // recording stopped (or a new one started) — nothing left to do
}
let diarizer_for_pass = diarizer.clone();
let wav_path = wav_path_for_diar.clone();
let spans = tauri::async_runtime::spawn_blocking(move || {
diarizer_for_pass.diarize(&wav_path)
})
.await;
let spans = match spans {
Ok(Ok(spans)) => spans,
Ok(Err(e)) => {
tracing::warn!("live diarization pass failed: {e}");
continue;
}
Err(e) => {
tracing::warn!("live diarization task failed: {e}");
continue;
}
};
let speakers = {
let mut segs = segments_for_diar.lock().unwrap_or_else(|e| e.into_inner());
diarizer.assign(&mut segs, &spans);
let names = names_for_diar.lock().unwrap_or_else(|e| e.into_inner());
speaker_infos_from_segments(&segs, &names)
};
let _ = app_for_diar.emit(
"diarization://updated",
serde_json::json!({ "meetingId": meeting_id_for_diar, "speakers": speakers }),
);
}
});
}
*guard = Some(RecordingSession {
meeting_id: meeting_id.clone(),
capture,
mic_capture,
retention: args.record,
wav_path,
started_at: std::time::Instant::now(),
transcription_worker,
segments,
active_backend,
model_id,
diarizer,
speaker_names,
});
drop(guard);
crate::update_tray_tooltip(&app, "WhispAssist — recording");
let _ = app.emit(
"recording://state",
serde_json::json!({ "meetingId": meeting_id, "state": "recording", "elapsedMs": 0 }),
);
Ok(meeting_id)
}
#[tauri::command]
pub async fn stop_recording(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<()> {
let mut guard = state.session.lock().await;
let session = match guard.take() {
Some(s) if s.meeting_id == meeting_id => s,
Some(s) => {
*guard = Some(s);
return Err(WaError::new(
"recording",
"meeting_id does not match the active recording",
));
}
None => {
return Err(WaError::new(
"recording",
"no meeting is currently recording",
))
}
};
drop(guard);
let summary = WasapiCapture
.stop(session.capture)
.map_err(|e| WaError::new("audio", e.to_string()))?;
// Stop the mic too (FR-CAP-7) so the mixer sees both sinks drop and closes
// `frame_tx`; ignore its summary/errors — the loopback WAV is the recording.
if let Some(mic) = session.mic_capture {
let _ = WasapiCapture.stop(mic);
}
// Both capture threads have now dropped their `FrameSink`s; the transcription
// worker's `recv()` returns `Err` and the worker exits on its own — join to
// guarantee it has fully drained the audio before we act on retention.
let _ = session.transcription_worker.join();
let mut segments = session
.segments
.lock()
.map(|g| g.clone())
.unwrap_or_default();
let segment_count = segments.len();
// T4.1/4.2: one authoritative diarization pass over the now-complete
// recording (ADR-0005's "post-stop pass"), refining whatever the live
// provisional passes (T4.3) produced. Skipped if diarization models
// aren't installed — `speaker_infos_from_segments` then falls back to
// the single pre-diarization "S1" placeholder, same as before Phase 4.
if let Some(diarizer) = session.diarizer.clone() {
let diarizer_for_task = diarizer.clone();
let wav_path = session.wav_path.clone();
match tauri::async_runtime::spawn_blocking(move || diarizer_for_task.diarize(&wav_path))
.await
{
Ok(Ok(spans)) => diarizer.assign(&mut segments, &spans),
Ok(Err(e)) => tracing::warn!("final diarization pass failed: {e}"),
Err(e) => tracing::warn!("final diarization task failed: {e}"),
}
}
let speaker_names = session
.speaker_names
.lock()
.map(|g| g.clone())
.unwrap_or_default();
let speakers = speaker_infos_from_segments(&segments, &speaker_names);
let backend_used = session
.active_backend
.lock()
.map(|b| b.as_str().to_string())
.unwrap_or_else(|_| BackendId::Cpu.as_str().to_string());
// T2.10: persist transcript.json (via finalize_meeting) and notes.md
// *before* touching the working WAV, so a crash here still leaves a
// recoverable, regenerable meeting.
let template_id = state
.store
.finalize_meeting(
&meeting_id,
FinalizeMeeting {
segments: segments.clone(),
speakers: speakers.clone(),
duration_secs: (summary.duration_ms / 1000) as i64,
recorded: session.retention,
language: None,
backend_used: Some(backend_used),
model_used: Some(session.model_id.clone()),
},
)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let template = template_id
.as_deref()
.and_then(crate::notes::note_template_by_id);
let notes_md =
crate::notes::MarkdownNotes.to_markdown(&segments, &speakers, None, template.as_ref());
let _ = state.store.update_notes(&meeting_id, &notes_md).await;
let _ = app.emit(
"transcript://finalized",
serde_json::json!({ "meetingId": meeting_id, "segmentCount": segment_count }),
);
// ADR-0009: delete the working WAV only after the transcript is finalized
// above, and only when retention is off.
if !session.retention {
let _ = std::fs::remove_file(&session.wav_path);
} else if crate::vault::is_unlocked() {
// Seal the retained recording at rest when the vault is unlocked (T8.8).
// Runs before the sync enqueue below, so any uploaded copy is ciphertext.
if let Ok(raw) = std::fs::read(&session.wav_path) {
if let Ok(sealed) = crate::vault::seal(&raw) {
let _ = std::fs::write(&session.wav_path, sealed);
}
}
}
// Sync-on-finalize (T9.5, FR-SYNC-5): enqueue configured artifacts for
// finalize-trigger targets and pump in the background so stop returns
// promptly. Runs after the WAV-retention decision above, so a deleted
// working recording is simply skipped (its file is gone).
if load_settings().sync_enabled {
let store = state.store.clone();
let app_sync = app.clone();
let mid = meeting_id.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = enqueue_meeting_sync(store.as_ref(), &mid, None, true).await {
tracing::warn!("sync enqueue on finalize failed: {e:?}");
}
pump_sync(&app_sync, store.as_ref()).await;
});
}
crate::update_tray_tooltip(&app, "WhispAssist — idle");
let _ = app.emit(
"recording://state",
serde_json::json!({ "meetingId": meeting_id, "state": "stopped", "elapsedMs": summary.duration_ms }),
);
Ok(())
}
/// Abandon the in-progress recording: stop capture, drop the transcript, and
/// delete the meeting row + its working files entirely — for a recording that
/// was started by mistake. Unlike `stop_recording`, nothing is finalized,
/// transcribed further, diarized, retained, or synced.
#[tauri::command]
pub async fn cancel_recording(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<()> {
let mut guard = state.session.lock().await;
let session = match guard.take() {
Some(s) if s.meeting_id == meeting_id => s,
Some(s) => {
*guard = Some(s);
return Err(WaError::new(
"recording",
"meeting_id does not match the active recording",
));
}
None => {
return Err(WaError::new(
"recording",
"no meeting is currently recording",
))
}
};
drop(guard);
// Stop both captures so their frame sinks drop and the transcription worker
// exits; join it so nothing is still touching the files we're about to delete.
let _ = WasapiCapture.stop(session.capture);
if let Some(mic) = session.mic_capture {
let _ = WasapiCapture.stop(mic);
}
let _ = session.transcription_worker.join();
// Remove the DB row + the whole meeting folder (working audio.wav included).
state
.store
.delete_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
crate::update_tray_tooltip(&app, "WhispAssist — idle");
let _ = app.emit(
"recording://state",
serde_json::json!({ "meetingId": meeting_id, "state": "cancelled", "elapsedMs": 0 }),
);
Ok(())
}
#[tauri::command]
pub async fn pause_recording(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<()> {
let guard = state.session.lock().await;
let session = guard
.as_ref()
.filter(|s| s.meeting_id == meeting_id)
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
WasapiCapture
.pause(&session.capture)
.map_err(|e| WaError::new("audio", e.to_string()))?;
if let Some(mic) = session.mic_capture.as_ref() {
WasapiCapture
.pause(mic)
.map_err(|e| WaError::new("audio", e.to_string()))?;
}
drop(guard);
let _ = app.emit(
"recording://state",
serde_json::json!({ "meetingId": meeting_id, "state": "paused", "elapsedMs": 0 }),
);
Ok(())
}
#[tauri::command]
pub async fn resume_recording(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<()> {
let guard = state.session.lock().await;
let session = guard
.as_ref()
.filter(|s| s.meeting_id == meeting_id)
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
WasapiCapture
.resume(&session.capture)
.map_err(|e| WaError::new("audio", e.to_string()))?;
if let Some(mic) = session.mic_capture.as_ref() {
WasapiCapture
.resume(mic)
.map_err(|e| WaError::new("audio", e.to_string()))?;
}
drop(guard);
let _ = app.emit(
"recording://state",
serde_json::json!({ "meetingId": meeting_id, "state": "recording", "elapsedMs": 0 }),
);
Ok(())
}
/// Toggle audio retention mid-meeting (ADR-0009, FR-REC-1).
#[tauri::command]
pub async fn set_recording_retention(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
record: bool,
) -> WaResult<()> {
if record && !load_settings().consent_acknowledged {
return Err(WaError::new(
"consent",
"recording consent has not been acknowledged yet",
));
}
let mut guard = state.session.lock().await;
let session = guard
.as_mut()
.filter(|s| s.meeting_id == meeting_id)
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
session.retention = record;
drop(guard);
let _ = app.emit(
"recording://retention",
serde_json::json!({ "meetingId": meeting_id, "record": record }),
);
Ok(())
}
/// Record the one-time recording-consent acknowledgment (FR-REC-2).
#[tauri::command]
pub async fn acknowledge_recording_consent() -> WaResult<()> {
let mut settings = load_settings();
settings.consent_acknowledged = true;
save_settings(&settings)
}
// ---- Speakers (Phase 4) ----
/// Re-renders and persists `notes.md` from a finalized meeting's current
/// (post-rename/post-merge) segments+speakers, and tells the frontend what
/// changed (T4.5/4.6, FR-SPK-3/5). This is what keeps `export_meeting` — which
/// just copies the already-rendered `notes.md` — in sync with naming changes
/// made after the meeting ends; `transcript.json` itself is untouched.
async fn refresh_notes_and_notify(
app: &AppHandle,
state: &State<'_, AppState>,
meeting_id: &MeetingId,
) -> WaResult<Vec<SpeakerInfo>> {
let meeting = state
.store
.get_meeting(meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let template = meeting
.template_id
.as_deref()
.and_then(crate::notes::note_template_by_id);
let notes_md = crate::notes::MarkdownNotes.to_markdown(
&meeting.segments,
&meeting.speakers,
None,
template.as_ref(),
);
let _ = state.store.update_notes(meeting_id, &notes_md).await;
let _ = app.emit(
"diarization://updated",
serde_json::json!({ "meetingId": meeting_id, "speakers": meeting.speakers }),
);
Ok(meeting.speakers)
}
/// Name a speaker; applies to that speaker's past & future segments (T4.4,
/// FR-SPK-2). Segments only ever carry the internal label ("S1"…) — never
/// rewritten — so persisting the label→name mapping here is enough to cover
/// both past and future segments once names are resolved at render time
/// (FR-SPK-5). Works whether the meeting is still recording or already
/// finalized.
#[tauri::command]
pub async fn rename_speaker(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
label: String,
name: String,
) -> WaResult<()> {
state
.store
.rename_speaker(&meeting_id, &label, &name)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let guard = state.session.lock().await;
match guard.as_ref().filter(|s| s.meeting_id == meeting_id) {
Some(session) => {
// Still recording: transcript.json/notes.md don't exist on disk
// yet, so there's nothing to re-render — just refresh the live
// in-memory view (finalize builds notes.md from this same map
// at stop, T4.3/4.4).
let names = {
let mut names = session
.speaker_names
.lock()
.unwrap_or_else(|e| e.into_inner());
names.insert(label, name);
names.clone()
};
let segments = session
.segments
.lock()
.map(|g| g.clone())
.unwrap_or_default();
drop(guard);
let speakers = speaker_infos_from_segments(&segments, &names);
let _ = app.emit(
"diarization://updated",
serde_json::json!({ "meetingId": meeting_id, "speakers": speakers }),
);
}
None => {
drop(guard);
refresh_notes_and_notify(&app, &state, &meeting_id).await?;
}
}
Ok(())
}
/// Fold over-split speakers into one canonical label (T4.5, FR-SPK-3) — e.g.
/// diarization split one person into "S2" and "S3"; merging them shows one
/// name and one grouped paragraph in notes/export. Post-meeting only: while
/// still recording, the live provisional pass (T4.3) re-clusters from scratch
/// every tick, so a label merged now could mean something else by the next
/// tick.
#[tauri::command]
pub async fn merge_speakers(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
from: Vec<String>,
into: String,
) -> WaResult<()> {
let guard = state.session.lock().await;
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
return Err(WaError::new(
"recording",
"cannot merge speakers while this meeting is still recording — wait until it's stopped",
));
}
drop(guard);
state
.store
.merge_speakers(&meeting_id, &from, &into)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
refresh_notes_and_notify(&app, &state, &meeting_id).await?;
Ok(())
}
/// Name a speaker AND link them to a known `Participant` (T6.5/T6.6,
/// FR-SPK-4) — e.g. picked from the linked calendar event's attendee
/// dropdown rather than typed free-text. The shared `participant_id` is what
/// gives naming continuity across meetings the same person attends. Post-
/// meeting only, same reasoning as `merge_speakers`: the live provisional
/// diarization pass re-clusters from scratch every tick, so a label mapped
/// now could mean someone else by the next tick.
#[tauri::command]
pub async fn map_speaker_to_participant(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
label: String,
participant_id: String,
) -> WaResult<()> {
let guard = state.session.lock().await;
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
return Err(WaError::new(
"recording",
"cannot map a speaker to a participant while this meeting is still recording — wait until it's stopped",
));
}
drop(guard);
state
.store
.map_speaker_to_participant(&meeting_id, &label, &participant_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
refresh_notes_and_notify(&app, &state, &meeting_id).await?;
Ok(())
}
// ---- App metadata (About page) ----
/// Version + build commit for the About page. Version comes from Cargo; the
/// short commit hash is baked in at build time by `build.rs` (`WA_GIT_HASH`).
#[tauri::command]
pub async fn app_info() -> WaResult<serde_json::Value> {
Ok(serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"commit": env!("WA_GIT_HASH"),
}))
}
/// Opens an http(s) URL in the user's default browser (About page source link).
/// Windows-only (WA is Windows-native, ADR-0001). The scheme is validated so
/// this can't be coerced into launching a local path or program, and `explorer`
/// receives the URL as a single argv (no shell), so there's no injection surface.
#[tauri::command]
pub async fn open_url(url: String) -> WaResult<()> {
if !(url.starts_with("https://") || url.starts_with("http://")) {
return Err(WaError::new("app", "only http(s) URLs may be opened"));
}
#[cfg(windows)]
{
std::process::Command::new("explorer")
.arg(&url)
.spawn()
.map_err(|e| WaError::new("app", e.to_string()))?;
Ok(())
}
#[cfg(not(windows))]
{
let _ = url;
Err(WaError::new("app", "unsupported platform"))
}
}
// ---- Hardware + models (Phase 3) ----
#[tauri::command]
pub async fn hardware_status() -> WaResult<serde_json::Value> {
let settings = load_settings();
let backends = WinHardwareDetector.detect();
let active = backend_for(&settings);
let model_id = model_id_for(&settings);
Ok(serde_json::json!({
"backends": backends,
"active": active,
"modelSize": model_id,
// ponytail: no real-time factor measurement harness yet (needs a
// timed sample transcription); 1.0 stands in until T3.6 wires one up.
"estRtf": 1.0,
// NPU package state (T3.4 step 2) — drives the Settings ▸ Hardware
// download indicator: chip detected, its runtime staged, model fetched.
"npu": {
"present": crate::hardware::npu_hardware_present(),
"runtimeReady": crate::paths::npu_runtime_ready(),
"modelInstalled": npu_model_installed(),
},
// DirectML GPU package (P2): shares the ONNX model with the NPU path;
// only the runtime differs. `applicable` gates the Settings ▸ Hardware
// card so it only shows when DirectML would actually help this build.
"directml": {
"applicable": crate::hardware::directml_would_help(),
"runtimeReady": crate::paths::directml_runtime_ready(),
"modelInstalled": npu_model_installed(),
},
}))
}
/// Lists active render (playback) devices for the "Audio Devices" picker
/// (Settings ▸ Hardware) — loopback-only, matching the app's only capture
/// path (FR-CAP-1). Runs off the async runtime thread since device
/// enumeration is a blocking COM call.
#[tauri::command]
pub async fn list_audio_devices() -> WaResult<Vec<crate::audio::AudioDeviceInfo>> {
tauri::async_runtime::spawn_blocking(crate::audio::list_render_devices)
.await
.map_err(|e| WaError::new("audio", e.to_string()))?
.map_err(|e| WaError::new("audio", e.to_string()))
}
/// Enumerate capture (microphone) devices for the Settings "Microphone" picker
/// (FR-CAP-7). Blocking COM enumeration, so it runs off the async runtime thread.
#[tauri::command]
pub async fn list_input_devices() -> WaResult<Vec<crate::audio::AudioDeviceInfo>> {
tauri::async_runtime::spawn_blocking(crate::audio::list_capture_devices)
.await
.map_err(|e| WaError::new("audio", e.to_string()))?
.map_err(|e| WaError::new("audio", e.to_string()))
}
#[derive(Deserialize)]
pub struct SetPreferredBackendArgs {
pub backend: String, // "auto"|"npu"|"nvidia"|"amd"|"intel"|"cpu"
}
#[tauri::command]
pub async fn set_preferred_backend(args: SetPreferredBackendArgs) -> WaResult<()> {
let mut settings = load_settings();
settings.preferred_backend = args.backend;
save_settings(&settings)
}
/// True when the NPU ONNX Whisper model is downloaded (false on non-NPU builds).
fn npu_model_installed() -> bool {
#[cfg(feature = "npu")]
{
use crate::transcription::onnx_models;
onnx_models::is_installed(onnx_models::DEFAULT_ONNX_MODEL)
}
#[cfg(not(feature = "npu"))]
{
false
}
}
/// Downloads the Whisper ONNX model for the NPU engine, emitting `npu://download`
/// progress. Idempotent — skips artifacts already on disk. Also used by the
/// startup background fetch (see `lib.rs`).
#[cfg(feature = "npu")]
pub async fn download_npu_model(app: AppHandle) -> WaResult<()> {
use crate::transcription::onnx_models;
let app2 = app.clone();
onnx_models::download(
onnx_models::DEFAULT_ONNX_MODEL,
move |idx, received, total| {
let _ = app2.emit(
"npu://download",
serde_json::json!({ "stage": "model", "file": idx, "received": received, "total": total }),
);
},
)
.await
.map_err(|e| WaError::new("npu", e.to_string()))
}
/// Hosted OpenVINO runtime bundle (ORT 1.24.1 + OpenVINO 2025.4.1 DLLs, 7z).
/// Served from the repo's `runtime/` dir via gitea's `media` path (resolves the
/// Git LFS object, unlike `raw` which returns the pointer); overridable at
/// runtime via `WA_NPU_RUNTIME_URL`. Keep the SHA-256 in step with the file.
#[cfg(feature = "npu")]
const NPU_RUNTIME_URL: &str =
"https://git.dou.bet/iamdoubz/WhispAssist/media/branch/main/runtime/openvino.7z";
/// SHA-256 of the runtime bundle; empty string disables the integrity check.
#[cfg(feature = "npu")]
const NPU_RUNTIME_SHA256: &str = "ca0be9fc52c78ee623b152f790450b3d4020c5a7ebe99d27736455b308782191";
/// Hosted DirectML runtime bundle (ORT 1.24.1 DirectML-EP DLLs, 7z), same repo
/// `media` (LFS-resolving) path as the OpenVINO one; overridable via
/// `WA_DIRECTML_RUNTIME_URL`.
#[cfg(feature = "npu")]
const DIRECTML_RUNTIME_URL: &str =
"https://git.dou.bet/iamdoubz/WhispAssist/media/branch/main/runtime/directml.7z";
/// SHA-256 of the DirectML runtime bundle; empty string disables the check.
#[cfg(feature = "npu")]
const DIRECTML_RUNTIME_SHA256: &str =
"34369222fcc1be2e72a957b868b1976a90150ba704a06c9e8992c34ee368926b";
/// Stages the ONNX Runtime + OpenVINO DLLs into the app's NPU runtime dir by
/// downloading the hosted bundle and unzipping it (T3.4). `WA_NPU_RUNTIME_SRC`
/// (';'-separated dirs) is honored as a dev/offline override that copies local
/// DLLs instead of downloading.
#[cfg(feature = "npu")]
async fn stage_npu_runtime(app: &AppHandle) -> WaResult<()> {
if crate::paths::npu_runtime_ready() {
return Ok(());
}
let dir = crate::paths::npu_runtime_dir();
std::fs::create_dir_all(&dir).map_err(|e| WaError::new("npu", e.to_string()))?;
// Dev/offline override: copy DLLs from local dirs instead of downloading.
if let Some(src) = std::env::var_os("WA_NPU_RUNTIME_SRC") {
return stage_npu_runtime_from_local(app, &dir, &src);
}
let url = std::env::var("WA_NPU_RUNTIME_URL").unwrap_or_else(|_| NPU_RUNTIME_URL.to_string());
download_and_extract_runtime(
app,
&dir,
&url,
NPU_RUNTIME_SHA256,
crate::paths::npu_runtime_ready,
)
.await
}
/// Stages the DirectML ONNX Runtime into the app's DirectML runtime dir (P2) by
/// downloading the hosted 7z bundle and unpacking it. Overridable via
/// `WA_DIRECTML_RUNTIME_URL`; dev testing can also just set `ORT_DYLIB_PATH`.
#[cfg(feature = "npu")]
async fn stage_directml_runtime(app: &AppHandle) -> WaResult<()> {
if crate::paths::directml_runtime_ready() {
return Ok(());
}
let dir = crate::paths::directml_runtime_dir();
std::fs::create_dir_all(&dir).map_err(|e| WaError::new("directml", e.to_string()))?;
let url = std::env::var("WA_DIRECTML_RUNTIME_URL")
.unwrap_or_else(|_| DIRECTML_RUNTIME_URL.to_string());
download_and_extract_runtime(
app,
&dir,
&url,
DIRECTML_RUNTIME_SHA256,
crate::paths::directml_runtime_ready,
)
.await
}
#[cfg(feature = "npu")]
fn stage_npu_runtime_from_local(
app: &AppHandle,
dir: &Path,
src: &std::ffi::OsStr,
) -> WaResult<()> {
let mut copied = 0u32;
for d in std::env::split_paths(src) {
let Ok(entries) = std::fs::read_dir(&d) else {
continue;
};
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) == Some("dll") {
if let Some(name) = p.file_name() {
if std::fs::copy(&p, dir.join(name)).is_ok() {
copied += 1;
}
}
}
}
let _ = app.emit(
"npu://download",
serde_json::json!({ "stage": "runtime", "copied": copied }),
);
}
if !crate::paths::npu_runtime_ready() {
return Err(WaError::new(
"npu",
format!("staged {copied} dll(s) but onnxruntime.dll not among them"),
));
}
Ok(())
}
/// Downloads a runtime bundle (streaming progress + SHA-256 check) and unzips
/// its DLLs flat into `dir`. `sha256` empty disables the integrity check;
/// `ready` is the on-disk readiness predicate for the target runtime (NPU or
/// DirectML), so this one function serves both.
// ponytail: progress event + error domain stay "npu"/"npu://download" even on
// the DirectML path — cosmetic only, no frontend consumer for a directml
// channel yet. Split them out when the Settings UI grows a DirectML indicator.
#[cfg(feature = "npu")]
async fn download_and_extract_runtime(
app: &AppHandle,
dir: &Path,
url: &str,
sha256: &str,
ready: fn() -> bool,
) -> WaResult<()> {
use futures_util::StreamExt;
use sha2::{Digest, Sha256};
let resp = reqwest::get(url)
.await
.map_err(|e| WaError::new("npu", e.to_string()))?;
if !resp.status().is_success() {
return Err(WaError::new(
"npu",
format!("runtime download failed: HTTP {}", resp.status()),
));
}
let total = resp.content_length();
let tmp = dir.join("runtime.7z.part");
let mut file = std::fs::File::create(&tmp).map_err(|e| WaError::new("npu", e.to_string()))?;
let mut hasher = Sha256::new();
let mut received = 0u64;
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| WaError::new("npu", e.to_string()))?;
std::io::Write::write_all(&mut file, &chunk)
.map_err(|e| WaError::new("npu", e.to_string()))?;
hasher.update(&chunk);
received += chunk.len() as u64;
let _ = app.emit(
"npu://download",
serde_json::json!({ "stage": "runtime", "received": received, "total": total }),
);
}
drop(file);
let digest = format!("{:x}", hasher.finalize());
if !sha256.is_empty() && digest != sha256 {
let _ = std::fs::remove_file(&tmp);
return Err(WaError::new("npu", "runtime bundle checksum mismatch"));
}
// Un-7z off the async runtime (CPU/IO-bound).
let tmp_for_unzip = tmp.clone();
let dir_for_unzip = dir.to_path_buf();
tokio::task::spawn_blocking(move || extract_7z_flat(&tmp_for_unzip, &dir_for_unzip))
.await
.map_err(|e| WaError::new("npu", e.to_string()))?
.map_err(|e| WaError::new("npu", e))?;
let _ = std::fs::remove_file(&tmp);
if !ready() {
return Err(WaError::new(
"npu",
"runtime bundle extracted but onnxruntime.dll is missing",
));
}
Ok(())
}
/// Extract every file entry of a zip into `dir`, flattening paths to just the
/// file name (which also strips the archive's top folder — the bundles nest DLLs
/// under `directml/` or `openvino/` — and prevents path traversal).
#[cfg(feature = "npu")]
fn extract_7z_flat(archive_path: &Path, dir: &Path) -> Result<(), String> {
sevenz_rust2::decompress_file_with_extract_fn(archive_path, dir, |entry, reader, _dest| {
if entry.is_directory() {
return Ok(true);
}
// Flatten to just the file name, dropping any folder prefix.
let Some(name) = Path::new(entry.name()).file_name().and_then(|n| n.to_str()) else {
return Ok(true);
};
let mut out = std::fs::File::create(dir.join(name)).map_err(sevenz_rust2::Error::io)?;
std::io::copy(reader, &mut out).map_err(sevenz_rust2::Error::io)?;
Ok(true)
})
.map_err(|e| e.to_string())
}
/// Downloads everything the NPU engine needs (ONNX model + OpenVINO runtime) for
/// the Settings ▸ Hardware "download NPU package" action (T3.4 step 2).
#[tauri::command]
pub async fn download_npu_package(app: AppHandle) -> WaResult<()> {
#[cfg(feature = "npu")]
{
download_npu_model(app.clone()).await?;
stage_npu_runtime(&app).await?;
let _ = app.emit(
"npu://download",
serde_json::json!({ "stage": "done", "ready": crate::paths::npu_runtime_ready() }),
);
Ok(())
}
#[cfg(not(feature = "npu"))]
{
let _ = app;
Err(WaError::new("npu", "this build has no NPU support"))
}
}
/// Downloads everything the DirectML GPU engine needs — the same Whisper ONNX
/// model as the NPU path, plus the DirectML runtime (P2). The non-Vulkan GPU
/// path for AMD/Intel.
#[tauri::command]
pub async fn download_directml_package(app: AppHandle) -> WaResult<()> {
#[cfg(feature = "npu")]
{
download_npu_model(app.clone()).await?; // identical ONNX artifacts
stage_directml_runtime(&app).await?;
let _ = app.emit(
"directml://download",
serde_json::json!({ "stage": "done", "ready": crate::paths::directml_runtime_ready() }),
);
Ok(())
}
#[cfg(not(feature = "npu"))]
{
let _ = app;
Err(WaError::new(
"directml",
"this build has no ONNX/DirectML support",
))
}
}
#[tauri::command]
pub async fn list_models() -> WaResult<Vec<ModelInfo>> {
let settings = load_settings();
Ok(model_catalog::list(&model_id_for(&settings)))
}
/// The fixed segmentation+embedding pair (T4.7, FR-MODEL-1) — a separate
/// command rather than folding into `list_models` because they're a fixed
/// installable pair, not an interchangeable-size catalog like whisper's.
#[tauri::command]
pub async fn list_diarization_models() -> WaResult<Vec<ModelInfo>> {
Ok(crate::diarization::models::list())
}
#[derive(Deserialize)]
pub struct DownloadModelArgs {
pub kind: String, // "whisper" | "diar-seg" | "diar-emb"
pub id: String,
}
#[tauri::command]
pub async fn download_model(app: AppHandle, args: DownloadModelArgs) -> WaResult<()> {
let id = args.id;
let id_for_progress = id.clone();
let on_progress = move |received: u64, total: Option<u64>| {
let _ = app.emit(
"model://progress",
serde_json::json!({ "id": id_for_progress, "receivedBytes": received, "totalBytes": total }),
);
};
match args.kind.as_str() {
"whisper" => model_catalog::download(&id, on_progress)
.await
.map_err(|e| WaError::new("model", e.to_string())),
"diar-seg" | "diar-emb" => crate::diarization::models::download(&id, on_progress)
.await
.map_err(|e| WaError::new("model", e.to_string())),
other => Err(WaError::new(
"model",
format!("unknown model kind '{other}'"),
)),
}
}
#[tauri::command]
pub async fn remove_model(id: String) -> WaResult<()> {
// No `kind` in this command's contract — disambiguate by catalog
// membership instead; whisper/diarization ids never collide.
if crate::diarization::models::list()
.iter()
.any(|m| m.id == id)
{
return crate::diarization::models::remove(&id)
.map_err(|e| WaError::new("model", e.to_string()));
}
let settings = load_settings();
model_catalog::remove(&id, &model_id_for(&settings))
.map_err(|e| WaError::new("model", e.to_string()))
}
/// Batch re-transcribe a finished meeting with a different (typically larger)
/// model (T3.8, FR-TRX-3). Only works if the meeting's audio was retained.
#[tauri::command]
pub async fn reprocess_transcript(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
model: String,
) -> WaResult<()> {
let model_path = whisper_model_file(&model);
if !model_path.exists() {
return Err(WaError::new(
"model",
format!("model '{model}' is not installed"),
));
}
let wav_path = meeting_dir(&meeting_id).join("audio.wav");
if !wav_path.exists() {
return Err(WaError::new(
"transcription",
"no retained audio.wav to reprocess — enable recording retention for this meeting",
));
}
let settings = load_settings();
let backend = backend_for(&settings);
let segments = tauri::async_runtime::spawn_blocking({
let wav_path = wav_path.clone();
move || {
let (transcriber, _used) = load_transcriber(backend, &model_path)?;
transcriber.transcribe_file(&wav_path)
}
})
.await
.map_err(|e| WaError::new("transcription", e.to_string()))?
.map_err(|e| WaError::new("transcription", e.to_string()))?;
let meeting = state
.store
.get_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let duration_secs = segments
.last()
.map(|s| (s.end_ms / 1000) as i64)
.unwrap_or(meeting.duration_secs.unwrap_or(0));
let template = meeting
.template_id
.as_deref()
.and_then(crate::notes::note_template_by_id);
state
.store
.finalize_meeting(
&meeting_id,
FinalizeMeeting {
segments: segments.clone(),
speakers: meeting.speakers.clone(),
duration_secs,
recorded: meeting.recorded,
language: meeting.language,
backend_used: Some(backend.as_str().to_string()),
model_used: Some(model),
},
)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let notes_md = crate::notes::MarkdownNotes.to_markdown(
&segments,
&meeting.speakers,
None,
template.as_ref(),
);
let _ = state.store.update_notes(&meeting_id, &notes_md).await;
let _ = app.emit(
"transcript://finalized",
serde_json::json!({ "meetingId": meeting_id, "segmentCount": segments.len() }),
);
Ok(())
}
/// Re-run transcription from a `recovering` meeting's working `audio.wav`
/// (T2.8, FR-REL-1). CPU-bound, so it runs on a blocking task rather than
/// tying up an async worker.
#[tauri::command]
pub async fn resume_transcription(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<()> {
let settings = load_settings();
let model_id = model_id_for(&settings);
let model_path = whisper_model_file(&model_id);
if !model_path.exists() {
return Err(WaError::new(
"transcription",
format!(
"whisper model '{model_id}' not found at {}; download it from Settings first",
model_path.display()
),
));
}
let wav_path = meeting_dir(&meeting_id).join("audio.wav");
if !wav_path.exists() {
return Err(WaError::new(
"recovery",
"no working audio.wav to recover from",
));
}
// Note: deliberately doesn't emit `recording://state` — that event drives
// the main header's single live-session Record/Stop toggle, and reusing
// it here would make the header show a phantom "Stop" button for a
// recovery run that isn't a `RecordingSession` at all. The "recovering"
// badge in the meetings list is this operation's own progress signal.
let segments = tauri::async_runtime::spawn_blocking(move || {
WhisperTranscriber::load(&model_path, BackendId::Cpu)
.and_then(|transcriber| transcriber.transcribe_file(&wav_path))
})
.await
.map_err(|e| WaError::new("transcription", e.to_string()))?
.map_err(|e| WaError::new("transcription", e.to_string()))?;
let speakers = vec![SpeakerInfo {
label: "S1".to_string(),
display_name: None,
participant_id: None,
}];
let duration_secs = segments
.last()
.map(|s| (s.end_ms / 1000) as i64)
.unwrap_or(0);
let template_id = state
.store
.finalize_meeting(
&meeting_id,
FinalizeMeeting {
segments: segments.clone(),
speakers: speakers.clone(),
duration_secs,
// The working WAV surviving a crash is the only signal we have
// left about intent; keep it rather than silently discard it.
recorded: true,
language: None,
backend_used: Some(BackendId::Cpu.as_str().to_string()),
model_used: Some(model_id),
},
)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let template = template_id
.as_deref()
.and_then(crate::notes::note_template_by_id);
let notes_md =
crate::notes::MarkdownNotes.to_markdown(&segments, &speakers, None, template.as_ref());
let _ = state.store.update_notes(&meeting_id, &notes_md).await;
let _ = app.emit(
"transcript://finalized",
serde_json::json!({ "meetingId": meeting_id, "segmentCount": segments.len() }),
);
Ok(())
}
// ---- Meetings / storage (Phase 2) ----
#[tauri::command]
pub async fn list_meetings(
state: State<'_, AppState>,
query: Option<String>,
tag: Option<String>,
participant_id: Option<String>,
from: Option<i64>,
to: Option<i64>,
) -> WaResult<Vec<MeetingListItem>> {
state
.store
.list_meetings(crate::storage::MeetingFilter {
query,
tag,
participant_id,
from,
to,
})
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Replaces a meeting's complete tag set (Phase 8, FR-SEARCH-2).
#[tauri::command]
pub async fn set_tags(
state: State<'_, AppState>,
meeting_id: MeetingId,
tags: Vec<String>,
) -> WaResult<()> {
state
.store
.set_tags(&meeting_id, &tags)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// All known tag names, sorted, for filter/autocomplete UI (Phase 8, FR-SEARCH-2).
#[tauri::command]
pub async fn list_tags(state: State<'_, AppState>) -> WaResult<Vec<String>> {
state
.store
.list_tags()
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1) —
/// distinct from `list_meetings`' `query`, which only substring-matches the
/// title.
#[tauri::command]
pub async fn search(state: State<'_, AppState>, query: String) -> WaResult<Vec<SearchHit>> {
state
.store
.search(&query)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Built-in note templates for a picker at recording-start time (Phase 8,
/// T8.1, FR-NOTE-5).
#[tauri::command]
pub async fn list_note_templates() -> WaResult<Vec<crate::notes::NoteTemplate>> {
Ok(crate::notes::built_in_note_templates())
}
#[tauri::command]
pub async fn get_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -> WaResult<Meeting> {
state
.store
.get_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
#[tauri::command]
pub async fn delete_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -> WaResult<()> {
let guard = state.session.lock().await;
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
return Err(WaError::new(
"recording",
"cannot delete a meeting that is currently recording",
));
}
drop(guard);
state
.store
.delete_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Return the `waaudio://` URL the in-app player loads for a meeting's recording
/// (FR-REC-5). The bytes are decrypted in memory on demand by `serve_recording`
/// — nothing plaintext is ever written to disk. Prechecks that a recording
/// exists and (if sealed) the vault is unlocked, so the UI can show a clear
/// error before playback; also clears any stale plaintext `audio.play.wav` left
/// by the previous file-based player.
#[tauri::command]
pub async fn recording_playback_path(meeting_id: MeetingId) -> WaResult<String> {
let id = meeting_id.clone();
tauri::async_runtime::spawn_blocking(move || {
let dir = meeting_dir(&id);
let wav = dir.join("audio.wav");
if !wav.exists() {
return Err(WaError::new(
"recording",
"no saved recording for this meeting",
));
}
// Cheap sealed check — read only the magic prefix, not the whole file.
let mut head = [0u8; 8];
let sealed = std::fs::File::open(&wav)
.and_then(|mut f| {
use std::io::Read;
let n = f.read(&mut head)?;
Ok(n)
})
.map(|n| crate::vault::is_sealed(&head[..n]))
.unwrap_or(false);
if sealed && !crate::vault::is_unlocked() {
return Err(WaError::new(
"recording",
"unlock the vault to play this recording",
));
}
// Drop any plaintext temp the old file-based player left behind.
let _ = std::fs::remove_file(dir.join("audio.play.wav"));
Ok(())
})
.await
.map_err(|e| WaError::new("recording", e.to_string()))??;
Ok(format!("http://waaudio.localhost/{meeting_id}"))
}
/// Custom-scheme handler backing the `waaudio://` URL: reads the meeting's
/// `audio.wav`, decrypts it in memory if sealed (T8.8), and streams the PCM to
/// the `<audio>` element with byte-range support for seeking. The plaintext
/// never touches the filesystem, so playback can't undermine encryption at rest.
pub(crate) fn serve_recording(
request: &tauri::http::Request<Vec<u8>>,
) -> tauri::http::Response<Vec<u8>> {
use tauri::http::{header, Response, StatusCode};
let fail = |code: StatusCode| {
Response::builder()
.status(code)
.body(Vec::new())
.expect("static error response")
};
// Guard the id against path traversal before joining it into a path.
let id = request.uri().path().trim_start_matches('/').to_string();
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return fail(StatusCode::BAD_REQUEST);
}
let raw = match std::fs::read(meeting_dir(&id).join("audio.wav")) {
Ok(b) => b,
Err(_) => return fail(StatusCode::NOT_FOUND),
};
let plain = match crate::vault::open(&raw) {
Ok(p) => p,
Err(_) => return fail(StatusCode::FORBIDDEN), // sealed + vault locked
};
let total = plain.len();
let base = || {
Response::builder()
.header(header::CONTENT_TYPE, "audio/wav")
.header(header::ACCEPT_RANGES, "bytes")
};
// Honour a single `Range` request so the player can seek.
if let Some(range) = request
.headers()
.get(header::RANGE)
.and_then(|v| v.to_str().ok())
{
if let Some((start, end)) = parse_byte_range(range, total) {
return base()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"))
.header(header::CONTENT_LENGTH, (end - start + 1).to_string())
.body(plain[start..=end].to_vec())
.unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR));
}
}
base()
.status(StatusCode::OK)
.header(header::CONTENT_LENGTH, total.to_string())
.body(plain)
.unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR))
}
/// Parse a single `bytes=start-end` range against `total`, returning inclusive
/// clamped indices. Supports open-ended (`start-`) and suffix (`-n`) forms; only
/// the common single-range case is handled (enough for an `<audio>` element).
fn parse_byte_range(header: &str, total: usize) -> Option<(usize, usize)> {
if total == 0 {
return None;
}
let (s, e) = header.strip_prefix("bytes=")?.split_once('-')?;
if s.is_empty() {
let n: usize = e.parse().ok()?;
return Some((total.saturating_sub(n), total - 1));
}
let start: usize = s.parse().ok()?;
let end = if e.is_empty() {
total - 1
} else {
e.parse::<usize>().ok()?.min(total - 1)
};
(start <= end && start < total).then_some((start, end))
}
/// Remove any leftover plaintext `audio.play.wav` files from the previous
/// file-based player, so no decrypted audio lingers on disk (T8.8).
pub(crate) fn cleanup_playback_temp() {
let Ok(entries) = std::fs::read_dir(crate::paths::meetings_dir()) else {
return;
};
for entry in entries.flatten() {
let play = entry.path().join("audio.play.wav");
if play.exists() {
let _ = std::fs::remove_file(play);
}
}
}
#[tauri::command]
pub async fn update_notes(
state: State<'_, AppState>,
meeting_id: MeetingId,
markdown: String,
) -> WaResult<()> {
state
.store
.update_notes(&meeting_id, &markdown)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// `format` ∈ `md | pdf | docx | bundle`. `dest` is a file path for
/// `md`/`pdf`/`docx`, a destination folder for `bundle` (the frontend gets
/// it from a native Save/choose-folder dialog).
#[tauri::command]
pub async fn export_meeting(
state: State<'_, AppState>,
meeting_id: MeetingId,
dest: String,
format: String,
) -> WaResult<String> {
let dest_path = PathBuf::from(&dest);
export_meeting_to(&state, &meeting_id, &dest_path, &format).await?;
Ok(dest)
}
/// Bulk export (Phase 8, T8.5, FR-STORE-4) — every meeting matching the
/// tag/date filter, one file (or bundle folder) per meeting under `dest_dir`.
/// Reuses the same filter shape as `list_meetings`; a meeting that fails to
/// export (e.g. a "recovering" meeting with no notes yet) is skipped rather
/// than aborting the whole batch. Returns the count actually exported.
#[tauri::command]
pub async fn bulk_export_meetings(
state: State<'_, AppState>,
dest_dir: String,
format: String,
tag: Option<String>,
from: Option<i64>,
to: Option<i64>,
) -> WaResult<u32> {
let items = state
.store
.list_meetings(crate::storage::MeetingFilter {
tag,
from,
to,
..Default::default()
})
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let dir = PathBuf::from(&dest_dir);
std::fs::create_dir_all(&dir).map_err(|e| WaError::new("export", e.to_string()))?;
let mut count = 0u32;
for item in items {
let stem = bulk_export_stem(&item);
let dest_path = if format == "bundle" {
dir.join(stem)
} else {
dir.join(format!("{stem}.{format}"))
};
match export_meeting_to(&state, &item.id, &dest_path, &format).await {
Ok(()) => count += 1,
Err(e) => tracing::warn!("bulk export skipped meeting {}: {}", item.id, e.message),
}
}
Ok(count)
}
/// A filesystem-safe, collision-resistant filename stem: sanitized title +
/// an id prefix, since meeting titles are very often duplicates ("Untitled
/// meeting") and would otherwise silently overwrite each other in a batch.
fn bulk_export_stem(item: &MeetingListItem) -> String {
let safe_title: String = item
.title
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect();
let safe_title = safe_title.trim_matches('_');
let id_prefix = &item.id[..item.id.len().min(8)];
format!("{safe_title}_{id_prefix}")
}
async fn export_meeting_to(
state: &State<'_, AppState>,
meeting_id: &MeetingId,
dest_path: &Path,
format: &str,
) -> WaResult<()> {
let meeting = state
.store
.get_meeting(meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
match format {
"md" => {
crate::notes::MarkdownNotes
.export(
&meeting.notes_markdown,
dest_path,
crate::notes::ExportFormat::Md,
)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
"pdf" => {
crate::notes::MarkdownNotes
.export(
&meeting.notes_markdown,
dest_path,
crate::notes::ExportFormat::Pdf,
)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
"docx" => {
crate::notes::MarkdownNotes
.export(
&meeting.notes_markdown,
dest_path,
crate::notes::ExportFormat::Docx,
)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
"bundle" => {
std::fs::create_dir_all(dest_path)
.map_err(|e| WaError::new("export", e.to_string()))?;
let source_dir = meeting_dir(meeting_id);
// Audio is stored plaintext — copy as-is.
let audio_src = source_dir.join("audio.wav");
if audio_src.exists() {
std::fs::copy(&audio_src, dest_path.join("audio.wav"))
.map_err(|e| WaError::new("export", e.to_string()))?;
}
// transcript.json may be vault-sealed; export the decrypted content
// so the bundle is usable (T8.8).
let transcript_src = source_dir.join("transcript.json");
if transcript_src.exists() {
let bytes = std::fs::read(&transcript_src)
.map_err(|e| WaError::new("export", e.to_string()))?;
let plain =
crate::vault::open(&bytes).map_err(|e| WaError::new("vault", e.to_string()))?;
std::fs::write(dest_path.join("transcript.json"), plain)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
std::fs::write(dest_path.join("notes.md"), &meeting.notes_markdown)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
other => {
return Err(WaError::new(
"export",
format!("unsupported export format: {other}"),
))
}
}
Ok(())
}
// ---- LLM (Phase 5) ----
/// Builds the configured `LlmProvider`, or `None` if LLM integration is off
/// (default — the zero-egress state, FR-LLM-1).
fn llm_provider_from_settings(settings: &Settings) -> Option<Box<dyn crate::llm::LlmProvider>> {
match settings.llm_provider.as_str() {
"ollama" => Some(Box::new(crate::llm::OllamaProvider {
endpoint: settings.llm_endpoint.clone(),
model: settings.llm_model.clone(),
advanced: settings.llm_advanced.clone(),
})),
"custom" => Some(Box::new(crate::llm::OpenAiCompatProvider {
endpoint: settings.llm_endpoint.clone(),
model: settings.llm_model.clone(),
credential_ref: None, // local, unauthenticated custom endpoint (ADR-0007); Phase 10a sets this for hosted gateways
})),
_ => None, // "off"
}
}
#[tauri::command]
pub async fn llm_status() -> WaResult<serde_json::Value> {
let settings = load_settings();
let Some(provider) = llm_provider_from_settings(&settings) else {
return Ok(serde_json::json!({
"provider": "off", "reachable": false, "isLocal": true, "models": Vec::<String>::new(),
}));
};
let status = provider.status().await;
Ok(serde_json::json!({
"provider": status.provider,
"reachable": status.reachable,
"isLocal": status.is_local,
"models": status.models,
}))
}
#[derive(Deserialize)]
pub struct SetLlmProviderArgs {
pub provider: String, // "ollama" | "custom" | "off" (Phase 10a adds "anthropic" | "openai")
pub endpoint: Option<String>,
pub model: Option<String>,
}
/// Select/configure the LLM provider (T5.2, FR-LLM-1). Hosted providers
/// (`apiKey`, stored only in the OS credential store — never settings/DB)
/// land in Phase 10a.
#[tauri::command]
pub async fn set_llm_provider(args: SetLlmProviderArgs) -> WaResult<()> {
if !matches!(args.provider.as_str(), "ollama" | "custom" | "off") {
return Err(WaError::new(
"llm",
format!(
"provider '{}' is not available yet — hosted providers land in Phase 10a",
args.provider
),
));
}
let mut settings = load_settings();
settings.llm_provider = args.provider;
if let Some(endpoint) = args.endpoint {
settings.llm_endpoint = endpoint;
}
if let Some(model) = args.model {
settings.llm_model = model;
}
save_settings(&settings)
}
/// Detect a missing/unreachable provider and offer a hardware-aware model
/// suggestion (T5.7, FR-LLM-5). Read-only and on-demand — no background
/// polling (NFR-RES-1); the frontend calls this when Settings' LLM section
/// is open or `llm_status` comes back unreachable.
#[tauri::command]
pub async fn llm_setup_suggestions() -> WaResult<serde_json::Value> {
let best_backend = WinHardwareDetector.best(None);
let suggestion = crate::llm::suggest_ollama_model(best_backend.vram_mb);
Ok(serde_json::json!({
"ollamaInstalled": crate::llm::ollama_installed(),
"installUrl": "https://ollama.com/download/windows",
"suggestedModel": {
"id": suggestion.id,
"label": suggestion.label,
"approxSizeGb": suggestion.approx_size_gb,
},
}))
}
/// Guided model download via Ollama's `/api/pull` (T5.7, ADR-0007); streams
/// progress as `model://progress`, the same shape the whisper/diarization
/// catalogs use.
#[tauri::command]
pub async fn pull_ollama_model(app: AppHandle, model: String) -> WaResult<()> {
let endpoint = load_settings().llm_endpoint;
let model_for_progress = model.clone();
crate::llm::pull_model(&endpoint, &model, move |received, total| {
let _ = app.emit(
"model://progress",
serde_json::json!({ "id": model_for_progress, "receivedBytes": received, "totalBytes": total }),
);
})
.await
.map_err(|e| WaError::new("llm", e.to_string()))
}
/// Built-in LLM-prompt biases for common meeting shapes (T5.4) — distinct
/// from `notes::NoteTemplate` (Phase 8, T8.1/FR-NOTE-5), which structures
/// notes.md itself. Both use the same meeting-type identifiers
/// ("standup"/"retro"/"one-on-one") since they describe the same kinds of
/// meetings, but are two independent lookups. An unrecognized id is ignored
/// rather than erroring, so a stale/removed template id never blocks a summary.
fn summary_prompt_bias(id: &str) -> Option<&'static str> {
match id {
"standup" => Some(
"This is a daily standup. Focus the summary on what each person did, what's \
blocking them, and what's planned next.",
),
"retro" => Some(
"This is a retrospective. Focus the summary on what went well, what didn't, and \
concrete process changes to try.",
),
"one-on-one" => Some(
"This is a 1:1. Focus the summary on the individual's updates, concerns, and \
agreed next steps.",
),
_ => None,
}
}
/// Assembles the transcript + metadata (+ optional template) prompt (T5.4,
/// FR-LLM-2). Reuses `notes_markdown` (already speaker-tagged, kept in sync
/// by every rename/merge) as the transcript rather than re-rendering it.
fn build_prompt(meeting: &Meeting, template_id: Option<&str>) -> crate::llm::Prompt {
let participants: Vec<String> = meeting
.speakers
.iter()
.map(|s| s.display_name.clone().unwrap_or_else(|| s.label.clone()))
.collect();
let metadata = format!(
"Meeting: {}\nDuration: {} min\nParticipants: {}",
meeting.title,
meeting.duration_secs.unwrap_or(0) / 60,
if participants.is_empty() {
"unknown".to_string()
} else {
participants.join(", ")
},
);
crate::llm::Prompt {
transcript: meeting.notes_markdown.clone(),
metadata,
template: template_id
.and_then(summary_prompt_bias)
.map(str::to_string),
}
}
/// Generate a summary/decisions/action items for a finished meeting
/// (T5.4-5.6, FR-LLM-2/3/4). Streams tokens live via `llm://token`, persists
/// `summary.json`, and emits `llm://done` with the full result. Drafted
/// action items are NOT written to the `action_items` table here — the user
/// reviews/edits them first; `confirm_action_items` is what actually creates
/// rows (FR-LLM-3).
#[tauri::command]
pub async fn generate_summary(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
template_id: Option<String>,
) -> WaResult<()> {
let guard = state.session.lock().await;
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
return Err(WaError::new(
"llm",
"cannot generate a summary while this meeting is still recording — wait until it's stopped",
));
}
drop(guard);
let settings = load_settings();
let provider = llm_provider_from_settings(&settings).ok_or_else(|| {
WaError::new(
"llm",
"no LLM provider is configured — enable one in Settings first",
)
})?;
let meeting = state
.store
.get_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let prompt = build_prompt(&meeting, template_id.as_deref());
// `LlmProvider::summarize` streams tokens over a std (blocking) mpsc
// channel; drain it on its own thread so tokens reach the frontend as
// they arrive rather than batched after the whole reply completes.
let (tx, rx) = std::sync::mpsc::channel::<String>();
let app_for_tokens = app.clone();
let meeting_id_for_tokens = meeting_id.clone();
let forward = std::thread::spawn(move || {
while let Ok(text) = rx.recv() {
let _ = app_for_tokens.emit(
"llm://token",
serde_json::json!({ "meetingId": meeting_id_for_tokens, "text": text }),
);
}
});
let result = provider.summarize(prompt, tx).await;
let _ = forward.join(); // rx errs (and the thread exits) once summarize() drops its Sender
let summary = result.map_err(|e| WaError::new("llm", e.to_string()))?;
let summary_file = SummaryFile {
schema: 1,
generated_at: now_unix(),
provider: settings.llm_provider,
model: settings.llm_model,
summary_md: summary.summary_md,
decisions: summary.decisions,
action_items: summary.action_items,
};
let json = serde_json::to_string_pretty(&summary_file)
.map_err(|e| WaError::new("llm", e.to_string()))?;
// Seal at rest when the vault is unlocked (T8.8, FR-SEC-3); passthrough otherwise.
if let Ok(sealed) = crate::vault::seal(json.as_bytes()) {
let _ = std::fs::write(meeting_dir(&meeting_id).join("summary.json"), sealed);
}
let _ = app.emit(
"llm://done",
serde_json::json!({ "meetingId": meeting_id, "summary": summary_file }),
);
Ok(())
}
/// Persist reviewed/edited action items as confirmed tasks (T5.6, FR-LLM-3),
/// then schedule or cancel each one's local reminder to match (Phase 8,
/// T8.6, FR-CAL-5).
#[tauri::command]
pub async fn confirm_action_items(
state: State<'_, AppState>,
meeting_id: MeetingId,
items: Vec<ActionItem>,
) -> WaResult<()> {
let saved = state
.store
.save_action_items(&meeting_id, &items)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
for item in &saved {
let Some(id) = &item.id else { continue };
match (item.reminder_set, item.due_at) {
(true, Some(due_at)) => {
crate::reminders::schedule(id, &item.text, item.owner.as_deref(), due_at)
}
_ => crate::reminders::cancel(id),
}
}
Ok(())
}
/// Suggests 1-8 short topical tags from the transcript (T8.3, FR-SEARCH-2) —
/// same LLM path/prompt assembly as generate_summary, just a much shorter
/// non-streamed reply. Suggestions are NOT saved automatically; the caller
/// reviews/merges them and still calls set_tags to persist, same as a
/// manually typed tag.
#[tauri::command]
pub async fn generate_tags(
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<Vec<String>> {
let guard = state.session.lock().await;
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
return Err(WaError::new(
"llm",
"cannot generate tags while this meeting is still recording — wait until it's stopped",
));
}
drop(guard);
let settings = load_settings();
let provider = llm_provider_from_settings(&settings).ok_or_else(|| {
WaError::new(
"llm",
"no LLM provider is configured — enable one in Settings first",
)
})?;
let meeting = state
.store
.get_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let prompt = build_prompt(&meeting, None);
let text = format!("{}\n\n{}", prompt.metadata, prompt.transcript);
provider
.suggest_tags(&text)
.await
.map_err(|e| WaError::new("llm", e.to_string()))
}
// ---- Calendar / .pst (Phase 6) ----
/// Import events + attendees from a `.pst` (T6.1/T6.2, FR-CAL-1). The
/// `readpst` subprocess + file parsing is CPU/IO-bound and blocking, so it
/// runs off the async runtime thread. `readpst` doesn't expose granular
/// progress, so `pst://progress` is a start/done signal rather than
/// per-item — real per-item progress would mean reimplementing readpst's
/// internals, not worth it for a one-shot import.
///
/// Split from the `#[tauri::command]` wrapper so the startup auto-sync pass
/// (T6.2, `pst_auto_sync`) can call the same logic without a `State` extractor.
pub(crate) async fn import_pst_core(
app: &AppHandle,
store: &dyn crate::storage::Store,
path: String,
password: Option<String>,
) -> WaResult<u32> {
let events = tauri::async_runtime::spawn_blocking(move || {
PstSource.import(CalImport { path, password })
})
.await
.map_err(|e| WaError::new("calendar", e.to_string()))?
.map_err(|e| WaError::new("calendar", e.to_string()))?;
let total = events.len() as u32;
let _ = app.emit(
"pst://progress",
serde_json::json!({ "processed": 0, "total": total }),
);
let imported = store
.import_calendar_events(events)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let _ = app.emit(
"pst://progress",
serde_json::json!({ "processed": imported, "total": total }),
);
Ok(imported)
}
#[tauri::command]
pub async fn import_pst(
app: AppHandle,
state: State<'_, AppState>,
path: String,
password: Option<String>,
) -> WaResult<u32> {
import_pst_core(&app, state.store.as_ref(), path, password).await
}
/// Browse imported calendar events (T6.3, FR-CAL-2).
#[tauri::command]
pub async fn list_calendar_events(
state: State<'_, AppState>,
from: Option<i64>,
to: Option<i64>,
) -> WaResult<Vec<CalendarEvent>> {
state
.store
.list_calendar_events(from, to)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// A single event with its attendees (T6.3/T6.4, FR-CAL-1/3) — backs the
/// pre-meeting context panel and the speaker-naming attendee dropdown (T6.5).
#[tauri::command]
pub async fn get_calendar_event(
state: State<'_, AppState>,
event_id: String,
) -> WaResult<crate::storage::CalendarEventDetail> {
state
.store
.get_calendar_event(&event_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Link a meeting (current or historical) to a calendar event (T6.3/T6.6,
/// FR-CAL-2/4).
#[tauri::command]
pub async fn attach_meeting_to_event(
state: State<'_, AppState>,
meeting_id: MeetingId,
event_id: String,
) -> WaResult<()> {
state
.store
.attach_meeting_to_event(&meeting_id, &event_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Manually rename a meeting — recordings default to "Untitled meeting" with
/// no prior way to change that from the UI.
#[tauri::command]
pub async fn rename_meeting(
state: State<'_, AppState>,
meeting_id: MeetingId,
title: String,
) -> WaResult<()> {
let title = title.trim();
if title.is_empty() {
return Err(WaError::new("storage", "title cannot be empty"));
}
state
.store
.rename_meeting(&meeting_id, title)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
// ---- Sync / upload (Phase 9, ADR-0010) ----
/// Config payload for add/update (snake_case, matching the TS `SyncTargetConfig`
/// and the other serde structs). The `secret` (app password) is write-only — it
/// goes to the OS store, never back to the UI (FR-SYNC-6).
#[derive(Deserialize, Default)]
pub struct SyncTargetConfigInput {
pub id: Option<String>,
pub name: Option<String>,
pub kind: Option<String>, // webdav (default) | onedrive | dropbox | box
pub provider_hint: Option<String>,
pub base_url: Option<String>,
pub remote_base_path: Option<String>,
pub username: Option<String>,
pub secret: Option<String>,
pub enabled: Option<bool>,
pub upload_transcript: Option<bool>,
pub upload_notes: Option<bool>,
pub upload_summary: Option<bool>,
pub upload_recording: Option<bool>,
pub trigger_on_finalize: Option<bool>,
pub allow_plaintext_lan: Option<bool>,
pub encrypt_before_upload: Option<bool>,
}
#[tauri::command]
pub async fn list_sync_targets(state: State<'_, AppState>) -> WaResult<Vec<SyncTargetInfo>> {
// Never returns secrets (FR-SYNC-6) — row_to_info drops credential_ref.
let rows = state
.store
.list_sync_targets()
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
Ok(rows.iter().map(crate::sync::row_to_info).collect())
}
#[tauri::command]
pub async fn add_sync_target(
state: State<'_, AppState>,
config: SyncTargetConfigInput,
) -> WaResult<SyncTargetInfo> {
let id = uuid::Uuid::new_v4().to_string();
let credential_ref = format!("wa-sync-{id}");
// Secret goes to the OS credential store keyed by credential_ref, never the DB.
if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) {
crate::sync::credentials::set(&credential_ref, secret)
.map_err(|e| WaError::new("sync", e.to_string()))?;
}
let row = SyncTargetRow {
id,
name: config.name.unwrap_or_else(|| "WebDAV target".to_string()),
kind: config.kind.unwrap_or_else(|| "webdav".to_string()),
provider_hint: config.provider_hint,
base_url: config.base_url,
remote_base_path: config
.remote_base_path
.unwrap_or_else(|| "/WhispAssist".to_string()),
username: config.username,
credential_ref,
enabled: config.enabled.unwrap_or(false),
upload_transcript: config.upload_transcript.unwrap_or(true),
upload_notes: config.upload_notes.unwrap_or(true),
upload_summary: config.upload_summary.unwrap_or(true),
upload_recording: config.upload_recording.unwrap_or(false),
trigger_on_finalize: config.trigger_on_finalize.unwrap_or(true),
allow_plaintext_lan: config.allow_plaintext_lan.unwrap_or(false),
encrypt_before_upload: config.encrypt_before_upload.unwrap_or(false),
created_at: now_unix(),
};
state
.store
.add_sync_target(row.clone())
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
Ok(crate::sync::row_to_info(&row))
}
#[tauri::command]
pub async fn update_sync_target(
state: State<'_, AppState>,
config: SyncTargetConfigInput,
) -> WaResult<SyncTargetInfo> {
let id = config
.id
.clone()
.ok_or_else(|| WaError::new("sync", "update requires a target id"))?;
let mut row = state
.store
.get_sync_target(&id)
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
// A provided secret rotates the credential; credential_ref is preserved.
if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) {
crate::sync::credentials::set(&row.credential_ref, secret)
.map_err(|e| WaError::new("sync", e.to_string()))?;
}
if let Some(v) = config.name {
row.name = v;
}
if let Some(v) = config.provider_hint {
row.provider_hint = Some(v);
}
if let Some(v) = config.base_url {
row.base_url = Some(v);
}
if let Some(v) = config.remote_base_path {
row.remote_base_path = v;
}
if let Some(v) = config.username {
row.username = Some(v);
}
if let Some(v) = config.enabled {
row.enabled = v;
}
if let Some(v) = config.upload_transcript {
row.upload_transcript = v;
}
if let Some(v) = config.upload_notes {
row.upload_notes = v;
}
if let Some(v) = config.upload_summary {
row.upload_summary = v;
}
if let Some(v) = config.upload_recording {
row.upload_recording = v;
}
if let Some(v) = config.trigger_on_finalize {
row.trigger_on_finalize = v;
}
if let Some(v) = config.allow_plaintext_lan {
row.allow_plaintext_lan = v;
}
if let Some(v) = config.encrypt_before_upload {
row.encrypt_before_upload = v;
}
state
.store
.update_sync_target(row.clone())
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
Ok(crate::sync::row_to_info(&row))
}
#[tauri::command]
pub async fn remove_sync_target(state: State<'_, AppState>, id: String) -> WaResult<()> {
// Best-effort secret cleanup first, then the row (cascades its jobs).
if let Ok(row) = state.store.get_sync_target(&id).await {
let _ = crate::sync::credentials::delete(&row.credential_ref);
}
state
.store
.remove_sync_target(&id)
.await
.map_err(|e| WaError::new("sync", e.to_string()))
}
fn oauth_display_name(kind: &str) -> String {
match kind {
"onedrive" => "OneDrive",
"dropbox" => "Dropbox",
"box" => "Box",
other => other,
}
.to_string()
}
/// Begin OAuth 2.0 Authorization Code + PKCE linking for a secondary target
/// (loopback redirect). Returns the `authUrl` for the frontend to open; the flow
/// finishes in the background and emits `sync://linked` (T9.9, FR-SYNC-9).
#[tauri::command]
pub async fn begin_oauth_link(
app: AppHandle,
state: State<'_, AppState>,
kind: String,
) -> WaResult<serde_json::Value> {
use crate::sync::oauth;
let provider = oauth::provider_for(&kind)
.ok_or_else(|| WaError::new("sync", format!("unknown OAuth provider '{kind}'")))?;
// Requires an app registration; clear error until a client id is configured.
let client_id = provider
.client_id()
.map_err(|e| WaError::new("sync", e.to_string()))?;
let pkce = oauth::pkce_pair();
let csrf_state = uuid::Uuid::new_v4().to_string();
let loopback =
oauth::LoopbackRedirect::bind().map_err(|e| WaError::new("sync", e.to_string()))?;
let redirect_uri = loopback.redirect_uri.clone();
let auth_url = oauth::build_auth_url(
&provider,
&client_id,
&redirect_uri,
&pkce.challenge,
&csrf_state,
)
.map_err(|e| WaError::new("sync", e.to_string()))?;
// Finish the handshake off-thread: wait for the redirect, exchange the code,
// store tokens, and create the target. The frontend opens `authUrl` and
// listens for `sync://linked`.
let store = state.store.clone();
let app_bg = app.clone();
let verifier = pkce.verifier;
tauri::async_runtime::spawn(async move {
let fail = |msg: String| {
let _ = app_bg.emit(
"sync://linked",
serde_json::json!({ "ok": false, "kind": kind, "error": msg }),
);
};
// Blocking single-shot accept of the browser redirect.
let code =
match tauri::async_runtime::spawn_blocking(move || loopback.wait_for_code(&csrf_state))
.await
{
Ok(Ok(code)) => code,
_ => return fail("authorization was cancelled or failed".into()),
};
let tokens = match oauth::exchange_code(
&provider,
&client_id,
&code,
&verifier,
&redirect_uri,
)
.await
{
Ok(t) => t,
Err(e) => return fail(e.to_string()),
};
let id = uuid::Uuid::new_v4().to_string();
let credential_ref = format!("wa-sync-{id}");
let Ok(json) = serde_json::to_string(&tokens) else {
return fail("could not serialize tokens".into());
};
if let Err(e) = crate::sync::credentials::set(&credential_ref, &json) {
return fail(e.to_string());
}
let row = SyncTargetRow {
id,
name: oauth_display_name(&kind),
kind: kind.clone(),
provider_hint: None,
base_url: None,
remote_base_path: "/WhispAssist".to_string(),
username: None,
credential_ref,
enabled: true,
upload_transcript: true,
upload_notes: true,
upload_summary: true,
upload_recording: false,
trigger_on_finalize: true,
allow_plaintext_lan: false,
encrypt_before_upload: false,
created_at: now_unix(),
};
if let Err(e) = store.add_sync_target(row).await {
return fail(e.to_string());
}
let _ = app_bg.emit(
"sync://linked",
serde_json::json!({ "ok": true, "kind": kind.clone() }),
);
});
Ok(serde_json::json!({ "authUrl": auth_url }))
}
#[tauri::command]
pub async fn retry_sync_job(
app: AppHandle,
state: State<'_, AppState>,
job_id: String,
) -> WaResult<()> {
let mut job = state
.store
.get_sync_job(&job_id)
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
// Clear the backoff so the pump picks it up immediately.
job.status = "pending".to_string();
job.next_attempt_at = None;
job.updated_at = now_unix();
state
.store
.update_sync_job(job)
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
pump_sync(&app, state.store.as_ref()).await;
Ok(())
}
/// Provider-specific setup tips surfaced on a failed/attempted test (T9.4).
fn provider_setup_hints(hint: Option<&str>) -> Vec<&'static str> {
match hint {
Some("seafile") => {
vec!["Enable SeafDAV on the server; it's off by default and may need LOCK disabled."]
}
Some("synology") => {
vec!["Install & enable the WebDAV Server package; prefer the HTTPS port."]
}
Some("nextcloud") | Some("owncloud") => {
vec!["Use an app password (Settings → Security), not your account password."]
}
_ => vec![],
}
}
/// Reachability + auth test for a target — either an existing one (by `id`) or an
/// unsaved config carrying an inline `secret` (T9.4, FR-SYNC-4).
#[tauri::command]
pub async fn test_sync_target(
state: State<'_, AppState>,
config: SyncTargetConfigInput,
) -> WaResult<serde_json::Value> {
use crate::sync::SyncTarget;
let hints = provider_setup_hints(config.provider_hint.as_deref());
let (target, temp_ref): (Box<dyn SyncTarget>, Option<String>) =
if let Some(id) = config.id.clone() {
let row = state
.store
.get_sync_target(&id)
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
if row.kind == "webdav" {
// Editing an existing webdav target: test the values on screen but
// reuse the STORED password (via its credential_ref) unless the user
// typed a new one — so "Test" works after a URL fix without having to
// re-enter the password.
let (credential_ref, temp_ref) =
if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) {
let temp_ref = format!("wa-sync-test-{}", uuid::Uuid::new_v4());
crate::sync::credentials::set(&temp_ref, secret)
.map_err(|e| WaError::new("sync", e.to_string()))?;
(temp_ref.clone(), Some(temp_ref))
} else {
(row.credential_ref.clone(), None)
};
let target = crate::sync::WebDavTarget {
base_url: config
.base_url
.clone()
.or(row.base_url.clone())
.unwrap_or_default(),
provider_hint: config.provider_hint.clone().or(row.provider_hint.clone()),
remote_base_path: config
.remote_base_path
.clone()
.unwrap_or(row.remote_base_path.clone()),
username: config
.username
.clone()
.or(row.username.clone())
.unwrap_or_default(),
credential_ref,
third_party: false,
allow_plaintext_lan: config
.allow_plaintext_lan
.unwrap_or(row.allow_plaintext_lan),
};
(Box::new(target), temp_ref)
} else {
// Non-webdav (OAuth) target — dispatch to its concrete impl as-is.
let target = crate::sync::build_sync_target(&row)
.map_err(|e| WaError::new("sync", e.to_string()))?;
(target, None)
}
} else {
// Unsaved WebDAV target: stash the secret under a temp credential_ref so
// the same resolve-at-use path works, then clean it up after the test.
let base_url = config
.base_url
.clone()
.ok_or_else(|| WaError::new("sync", "test requires a base_url"))?;
let temp_ref = format!("wa-sync-test-{}", uuid::Uuid::new_v4());
if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) {
crate::sync::credentials::set(&temp_ref, secret)
.map_err(|e| WaError::new("sync", e.to_string()))?;
}
let target = crate::sync::WebDavTarget {
base_url,
provider_hint: config.provider_hint.clone(),
remote_base_path: config
.remote_base_path
.clone()
.unwrap_or_else(|| "/WhispAssist".to_string()),
username: config.username.clone().unwrap_or_default(),
credential_ref: temp_ref.clone(),
third_party: config.kind.as_deref().unwrap_or("webdav") != "webdav",
allow_plaintext_lan: config.allow_plaintext_lan.unwrap_or(false),
};
(Box::new(target), Some(temp_ref))
};
let result = target.test().await;
if let Some(temp_ref) = temp_ref {
let _ = crate::sync::credentials::delete(&temp_ref);
}
match result {
Ok(()) => Ok(serde_json::json!({ "ok": true, "message": "Connected", "hints": hints })),
Err(e) => Ok(serde_json::json!({ "ok": false, "message": e.to_string(), "hints": hints })),
}
}
#[tauri::command]
pub async fn set_sync_enabled(enabled: bool) -> WaResult<()> {
let mut settings = load_settings();
settings.sync_enabled = enabled;
save_settings(&settings)
}
// ---- At-rest encryption vault (Phase 8, T8.8, FR-SEC-3) ----
#[tauri::command]
pub async fn vault_status() -> WaResult<serde_json::Value> {
Ok(serde_json::json!({
"enabled": crate::vault::is_enabled(),
"unlocked": crate::vault::is_unlocked(),
}))
}
#[derive(Deserialize)]
pub struct VaultPasswordArgs {
pub password: String,
}
/// Turn on the vault: derive a key from the password and start sealing artifacts
/// at rest. Leaves the vault unlocked for the session.
#[tauri::command]
pub async fn enable_vault(args: VaultPasswordArgs) -> WaResult<()> {
crate::vault::enable(&args.password).map_err(|e| WaError::new("vault", e.to_string()))
}
#[tauri::command]
pub async fn unlock_vault(args: VaultPasswordArgs) -> WaResult<()> {
crate::vault::unlock(&args.password).map_err(|e| WaError::new("vault", e.to_string()))
}
#[tauri::command]
pub async fn lock_vault() -> WaResult<()> {
crate::vault::lock();
Ok(())
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChangeVaultPasswordArgs {
pub old_password: String,
pub new_password: String,
}
#[tauri::command]
pub async fn change_vault_password(args: ChangeVaultPasswordArgs) -> WaResult<()> {
crate::vault::change_password(&args.old_password, &args.new_password)
.map_err(|e| WaError::new("vault", e.to_string()))
}
// ---- Sync durable queue (T9.5) ----
/// Give up auto-retrying after this many failures; the job stays `failed` with
/// no `next_attempt_at` until the user hits "retry".
const SYNC_MAX_ATTEMPTS: i64 = 6;
/// Exponential backoff: 30s, 60s, 120s … capped at 1h.
fn sync_backoff_secs(attempts: i64) -> i64 {
(30_i64.saturating_mul(1_i64 << attempts.min(7))).min(3600)
}
fn file_sha256(path: &Path) -> Option<String> {
use sha2::{Digest, Sha256};
let bytes = std::fs::read(path).ok()?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
Some(format!("{:x}", hasher.finalize()))
}
/// Which (artifact, filename) pairs this target is configured to upload.
fn artifact_plan(target: &SyncTargetRow) -> Vec<(&'static str, &'static str)> {
let mut plan = Vec::new();
if target.upload_transcript {
plan.push(("transcript", "transcript.json"));
}
if target.upload_notes {
plan.push(("notes", "notes.md"));
}
if target.upload_summary {
plan.push(("summary", "summary.json"));
}
if target.upload_recording {
plan.push(("recording", "audio.wav"));
}
plan
}
fn job_to_info(row: &crate::storage::SyncJobRow) -> SyncJobInfo {
SyncJobInfo {
id: row.id.clone(),
target_id: row.target_id.clone(),
meeting_id: row.meeting_id.clone(),
artifact: row.artifact.clone(),
status: row.status.clone(),
attempts: row.attempts as u32,
bytes_sent: row.bytes_sent as u64,
bytes_total: row.bytes_total.map(|b| b as u64),
last_error: row.last_error.clone(),
}
}
fn emit_sync_job(app: &AppHandle, row: &crate::storage::SyncJobRow) {
// camelCase event shape per docs/04-api-contracts.md (`onSyncJob`).
let _ = app.emit(
"sync://job",
serde_json::json!({
"jobId": row.id,
"meetingId": row.meeting_id,
"targetId": row.target_id,
"artifact": row.artifact,
"status": row.status,
"bytesSent": row.bytes_sent,
"bytesTotal": row.bytes_total,
"attempts": row.attempts,
"error": row.last_error,
}),
);
}
/// Enqueue a meeting's configured artifacts for one or all enabled targets
/// (T9.5). `finalize_only` limits to targets with `trigger_on_finalize`. Files
/// that don't exist are skipped; unchanged files are deduped by SHA-256 in the
/// storage upsert. Returns the number of jobs (re)queued.
pub(crate) async fn enqueue_meeting_sync(
store: &dyn crate::storage::Store,
meeting_id: &MeetingId,
target_id: Option<&str>,
finalize_only: bool,
) -> Result<u32, WaError> {
let targets = store
.list_sync_targets()
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
let mut queued = 0u32;
for target in targets.iter().filter(|t| t.enabled) {
if target_id.is_some_and(|id| id != target.id) {
continue;
}
if finalize_only && !target.trigger_on_finalize {
continue;
}
let base = target.remote_base_path.trim_end_matches('/');
for (artifact, filename) in artifact_plan(target) {
let local = meeting_dir(meeting_id).join(filename);
if !local.exists() {
continue;
}
let bytes_total = std::fs::metadata(&local).ok().map(|m| m.len() as i64);
let job = crate::storage::SyncJobRow {
id: uuid::Uuid::new_v4().to_string(),
target_id: target.id.clone(),
meeting_id: meeting_id.clone(),
artifact: artifact.to_string(),
local_path: local.to_string_lossy().into_owned(),
remote_path: format!("{base}/{meeting_id}/{filename}"),
sha256: file_sha256(&local),
status: "pending".to_string(),
attempts: 0,
last_error: None,
next_attempt_at: None,
bytes_total,
bytes_sent: 0,
updated_at: now_unix(),
};
if store
.upsert_sync_job(job)
.await
.map_err(|e| WaError::new("sync", e.to_string()))?
{
queued += 1;
}
}
}
Ok(queued)
}
/// Upload one job's file: ensure the remote dir, PUT, report bytes sent.
async fn upload_job(
target: &dyn crate::sync::SyncTarget,
row: &crate::storage::SyncJobRow,
encrypt: bool,
mut on_progress: impl FnMut(u64, u64) + Send + 'static,
) -> Result<u64, crate::sync::SyncError> {
use crate::sync::SyncError;
let remote_dir = row
.remote_path
.rsplit_once('/')
.map(|(dir, _)| dir)
.unwrap_or("");
target.ensure_dir(remote_dir).await?;
let (tx, rx) = std::sync::mpsc::channel();
// Forward live upload progress off the async task (the sink is a blocking
// std channel): throttled so a large recording emits a steady stream of
// `sync://job` updates without flooding the UI. Ends when `put` drops `tx`.
let fallback_total = row.bytes_total.unwrap_or(0) as u64;
let drain = std::thread::spawn(move || {
let mut last = 0u64;
let mut last_emit = std::time::Instant::now() - std::time::Duration::from_millis(500);
while let Ok((sent, total)) = rx.recv() {
last = sent;
if last_emit.elapsed() >= std::time::Duration::from_millis(250) {
on_progress(sent, total);
last_emit = std::time::Instant::now();
}
}
last
});
if encrypt {
// Client-side encryption before upload (T9.12, FR-SYNC-10): seal to a
// temp file (idempotent if already sealed at rest) so the destination
// only ever holds ciphertext. Requires the vault to be unlocked.
let bytes = tokio::fs::read(&row.local_path)
.await
.map_err(|e| SyncError::Upload(e.to_string()))?;
let sealed =
crate::vault::ensure_sealed(&bytes).map_err(|e| SyncError::Upload(e.to_string()))?;
let tmp = std::env::temp_dir().join(format!("wa-enc-{}.bin", row.id));
tokio::fs::write(&tmp, &sealed)
.await
.map_err(|e| SyncError::Upload(e.to_string()))?;
let result = target.put(&tmp, &row.remote_path, tx).await;
let _ = tokio::fs::remove_file(&tmp).await;
result?;
} else {
target
.put(Path::new(&row.local_path), &row.remote_path, tx)
.await?;
}
// `tx` is dropped now that `put` returned, so the drain thread finishes and
// hands back the final byte count it observed.
let sent = drain.join().unwrap_or(fallback_total);
Ok(if sent > 0 { sent } else { fallback_total })
}
/// Drive all due jobs once: upload each, emit `sync://job` on every transition,
/// apply exponential backoff on failure (T9.5). Called on finalize, on startup,
/// and on manual upload/retry — never on an idle timer (NFR-RES-1).
pub(crate) async fn pump_sync(app: &AppHandle, store: &dyn crate::storage::Store) {
let due = match store.claim_due_sync_jobs(now_unix()).await {
Ok(jobs) => jobs,
Err(e) => {
tracing::warn!("sync pump: claim failed: {e}");
return;
}
};
for mut job in due {
let target = match store.get_sync_target(&job.target_id).await {
Ok(t) => t,
Err(_) => continue, // target removed under us — its jobs cascaded away
};
job.status = "uploading".to_string();
job.updated_at = now_unix();
let _ = store.update_sync_job(job.clone()).await;
emit_sync_job(app, &job);
// Emit a live `sync://job` (status still "uploading") as bytes go out, so
// the UI shows a moving per-item progress bar (FR-SYNC-11).
let app_prog = app.clone();
let mut prog_row = job.clone();
let on_progress = move |sent: u64, _total: u64| {
prog_row.bytes_sent = sent as i64;
emit_sync_job(&app_prog, &prog_row);
};
let outcome = match crate::sync::build_sync_target(&target) {
Ok(t) => upload_job(t.as_ref(), &job, target.encrypt_before_upload, on_progress).await,
Err(e) => Err(e),
};
match outcome {
Ok(sent) => {
job.status = "done".to_string();
job.bytes_sent = sent as i64;
job.last_error = None;
job.next_attempt_at = None;
}
Err(e) => {
job.attempts += 1;
job.last_error = Some(e.to_string());
job.status = "failed".to_string();
job.next_attempt_at = (job.attempts < SYNC_MAX_ATTEMPTS)
.then(|| now_unix() + sync_backoff_secs(job.attempts));
}
}
job.updated_at = now_unix();
let _ = store.update_sync_job(job.clone()).await;
emit_sync_job(app, &job);
}
}
/// Manual "Upload now": (re)enqueue the meeting's artifacts and pump immediately.
/// Streams progress via `sync://job` events.
#[tauri::command]
pub async fn sync_meeting(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
target_id: Option<String>,
) -> WaResult<()> {
enqueue_meeting_sync(
state.store.as_ref(),
&meeting_id,
target_id.as_deref(),
false,
)
.await?;
pump_sync(&app, state.store.as_ref()).await;
Ok(())
}
#[tauri::command]
pub async fn sync_status(
state: State<'_, AppState>,
meeting_id: Option<MeetingId>,
) -> WaResult<Vec<SyncJobInfo>> {
let rows = state
.store
.list_sync_jobs(meeting_id.as_ref())
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
Ok(rows.iter().map(job_to_info).collect())
}
// ---- Feature briefs + MCP server (Phase 10, ADR-0011) ----
/// Distills a finished meeting into an agent-ready `FeatureBrief` (M1.4,
/// FR-MCP-4, T10.6) via the configured `LlmProvider` — no new egress. Writes
/// the sealed `briefs/<id>.json` + the `feature_briefs` index row only after
/// a successful distill; an LLM failure (off/unreachable) leaves nothing on
/// disk or in the DB.
#[tauri::command]
pub async fn create_feature_brief(
state: State<'_, AppState>,
meeting_id: MeetingId,
target_repo: Option<String>,
) -> WaResult<FeatureBrief> {
let guard = state.session.lock().await;
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
return Err(WaError::new(
"briefs",
"cannot create a feature brief while this meeting is still recording — wait until it's stopped",
));
}
drop(guard);
create_feature_brief_core(
&state.store,
&meeting_id,
target_repo.as_deref(),
&load_settings(),
)
.await
}
/// Core of `create_feature_brief`, factored out of the `State`-taking command
/// so it's callable directly from tests with an in-memory `Store` (T10.6
/// tests, `06-test-strategy.md` P10) without needing a live Tauri app.
async fn create_feature_brief_core(
store: &std::sync::Arc<dyn crate::storage::Store>,
meeting_id: &MeetingId,
target_repo: Option<&str>,
settings: &Settings,
) -> WaResult<FeatureBrief> {
use crate::briefs::FeatureBriefBuilder;
let provider = llm_provider_from_settings(settings).ok_or_else(|| {
WaError::new(
"llm",
"no LLM provider is configured — enable one in Settings first",
)
})?;
let builder = crate::briefs::LlmFeatureBriefBuilder {
store: store.clone(),
llm: provider,
};
// Nothing is written until this succeeds — an LLM failure (off/
// unreachable) returns here with disk/DB untouched.
let brief = builder
.build(meeting_id, target_repo)
.await
.map_err(|e| WaError::new("briefs", e.to_string()))?;
let meeting_title = store
.get_meeting(meeting_id)
.await
.map(|m| m.title)
.map_err(|e| WaError::new("storage", e.to_string()))?;
let generated_at = now_unix();
let brief_file = crate::storage::BriefFile {
schema: 1,
id: brief.id.clone(),
meeting_id: meeting_id.clone(),
generated_at,
provider: settings.llm_provider.clone(),
model: settings.llm_model.clone(),
title: brief.title.clone(),
problem: brief.problem.clone(),
desired_outcome: brief.desired_outcome.clone(),
acceptance_criteria: brief.acceptance_criteria.clone(),
target_repo: brief.target_repo.clone(),
context_excerpts: brief.context_excerpts.clone(),
source: crate::storage::BriefSource {
meeting_title,
at: generated_at,
},
};
let json = serde_json::to_string_pretty(&brief_file)
.map_err(|e| WaError::new("briefs", e.to_string()))?;
let sealed =
crate::vault::seal(json.as_bytes()).map_err(|e| WaError::new("vault", e.to_string()))?;
let briefs_dir = meeting_dir(meeting_id).join("briefs");
std::fs::create_dir_all(&briefs_dir).map_err(|e| WaError::new("briefs", e.to_string()))?;
let rel_path = format!("briefs/{}.json", brief.id);
let abs_path = briefs_dir.join(format!("{}.json", brief.id));
std::fs::write(&abs_path, sealed).map_err(|e| WaError::new("briefs", e.to_string()))?;
let row = crate::storage::FeatureBriefRow {
id: brief.id.clone(),
meeting_id: meeting_id.clone(),
title: brief.title.clone(),
target_repo: brief.target_repo.clone(),
path: rel_path,
exposed: false,
created_at: generated_at,
};
if let Err(e) = store.insert_feature_brief(row).await {
// The distill + file write already succeeded; don't leave an orphan
// file with no DB index behind if the row insert itself fails.
let _ = std::fs::remove_file(&abs_path);
return Err(WaError::new("storage", e.to_string()));
}
Ok(brief)
}
/// Lists briefs newest-first, optionally scoped to one meeting (M1.4).
#[tauri::command]
pub async fn list_feature_briefs(
state: State<'_, AppState>,
meeting_id: Option<MeetingId>,
) -> WaResult<Vec<FeatureBriefInfo>> {
state
.store
.list_feature_briefs(meeting_id.as_ref())
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Resolves the index row to its sealed `briefs/<id>.json` file and returns
/// the IPC subset (M1.4) — the file is the source of truth, the row is the
/// index (`docs/03-data-model.md`).
#[tauri::command]
pub async fn get_feature_brief(state: State<'_, AppState>, id: String) -> WaResult<FeatureBrief> {
let row = state
.store
.get_feature_brief_row(&id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let abs_path = meeting_dir(&row.meeting_id).join(&row.path);
let bytes = std::fs::read(&abs_path).map_err(|e| WaError::new("storage", e.to_string()))?;
let opened = crate::vault::open(&bytes).map_err(|e| WaError::new("vault", e.to_string()))?;
let brief_file: crate::storage::BriefFile =
serde_json::from_slice(&opened).map_err(|e| WaError::new("briefs", e.to_string()))?;
Ok(FeatureBrief {
id: brief_file.id,
meeting_id: brief_file.meeting_id,
title: brief_file.title,
problem: brief_file.problem,
desired_outcome: brief_file.desired_outcome,
acceptance_criteria: brief_file.acceptance_criteria,
target_repo: brief_file.target_repo,
context_excerpts: brief_file.context_excerpts,
})
}
/// Scope control: include/exclude a brief from the MCP server (FR-MCP-3).
#[tauri::command]
pub async fn set_brief_exposed(
state: State<'_, AppState>,
id: String,
exposed: bool,
) -> WaResult<()> {
state
.store
.set_brief_exposed(&id, exposed)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
#[tauri::command]
pub async fn mcp_status() -> WaResult<serde_json::Value> {
Err(not_implemented("mcp_status"))
}
/// Enable/disable the loopback MCP server; returns endpoint + token on enable (FR-MCP-1/6).
#[tauri::command]
pub async fn set_mcp_enabled(
_enabled: bool,
_transport: Option<String>,
_port: Option<u16>,
) -> WaResult<serde_json::Value> {
Err(not_implemented("set_mcp_enabled"))
}
/// Set exposure scope (none|selected|all) and whether recordings may be served (FR-MCP-3).
#[tauri::command]
pub async fn set_mcp_scope(_expose: String, _expose_recordings: Option<bool>) -> WaResult<()> {
Err(not_implemented("set_mcp_scope"))
}
#[tauri::command]
pub async fn mcp_access_log(_limit: Option<u32>) -> WaResult<Vec<McpAccessEntry>> {
Err(not_implemented("mcp_access_log"))
}
// ---- Agent push / task-tracker handoff (Phase 10c, later) ----
#[tauri::command]
pub async fn run_agent(
_brief_id: String,
_tool: String,
_repo_path: String,
) -> WaResult<serde_json::Value> {
Err(not_implemented("run_agent"))
}
#[tauri::command]
pub async fn create_issue_from_brief(
_brief_id: String,
_tracker: String,
_assign_copilot: Option<bool>,
) -> WaResult<serde_json::Value> {
Err(not_implemented("create_issue_from_brief"))
}
// ---- Settings + privacy (Phase 2/7) ----
#[tauri::command]
pub async fn get_settings() -> WaResult<Settings> {
Ok(load_settings())
}
#[tauri::command]
pub async fn update_settings(patch: serde_json::Value) -> WaResult<Settings> {
let mut current = serde_json::to_value(load_settings())
.map_err(|e| WaError::new("settings", e.to_string()))?;
if let (Some(base), Some(patch)) = (current.as_object_mut(), patch.as_object()) {
for (key, value) in patch {
base.insert(key.clone(), value.clone());
}
}
let merged: Settings =
serde_json::from_value(current).map_err(|e| WaError::new("settings", e.to_string()))?;
save_settings(&merged)?;
Ok(merged)
}
/// Pure assembly of the `privacy_self_check` response (shape fixed by
/// docs/04-api-contracts.md) from already-loaded settings + sync targets, so
/// it's unit-testable without touching the settings file or a DB.
fn privacy_self_check_json(
settings: &Settings,
sync_targets: Vec<SyncTargetInfo>,
) -> serde_json::Value {
let provider = llm_provider_from_settings(settings);
let (llm_endpoint, llm_is_local) = match &provider {
Some(p) => (settings.llm_endpoint.clone(), p.is_local()),
None => (String::new(), true), // off: no endpoint, trivially no egress
};
let mut allowlisted_hosts: Vec<String> = Vec::new();
if !llm_is_local {
if let Some(host) = reqwest::Url::parse(&llm_endpoint)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
{
allowlisted_hosts.push(host);
}
}
for target in sync_targets.iter().filter(|t| t.enabled) {
if let Some(host) = &target.host {
allowlisted_hosts.push(host.clone());
}
}
allowlisted_hosts.sort();
allowlisted_hosts.dedup();
let sync_targets_json: Vec<serde_json::Value> = sync_targets
.iter()
.map(|t| {
serde_json::json!({
"name": t.name,
"host": t.host.clone().unwrap_or_default(),
"thirdParty": t.third_party,
"tls": t.base_url.as_deref().is_some_and(|u| u.starts_with("https://")),
})
})
.collect();
serde_json::json!({
"llmEndpoint": llm_endpoint,
"llmIsLocal": llm_is_local,
"syncEnabled": settings.sync_enabled,
"syncTargets": sync_targets_json,
"allowlistedHosts": allowlisted_hosts,
})
}
/// Reports current egress + LLM endpoint so the UI can prove local-only handling (FR-SEC-2).
/// Enabled sync targets' hosts are folded into the allowlist (T9.8); a DB error is
/// swallowed to an empty list rather than failing the whole self-check.
#[tauri::command]
pub async fn privacy_self_check(state: State<'_, AppState>) -> WaResult<serde_json::Value> {
let settings = load_settings();
let sync_targets: Vec<SyncTargetInfo> = if settings.sync_enabled {
state
.store
.list_sync_targets()
.await
.map(|rows| rows.iter().map(crate::sync::row_to_info).collect())
.unwrap_or_default()
} else {
Vec::new()
};
Ok(privacy_self_check_json(&settings, sync_targets))
}
#[cfg(test)]
mod tests {
use super::*;
/// Extracts the real hosted DirectML bundle (LZMA2+BCJ 7z) and checks the
/// DLL lands flat. Skips if the binary isn't present (e.g. a lean checkout).
#[cfg(feature = "npu")]
#[test]
fn extract_7z_flat_unpacks_the_directml_bundle() {
let archive = std::path::Path::new("../runtime/directml.7z");
if !archive.exists() {
eprintln!("skip: {} not present", archive.display());
return;
}
let dir = std::env::temp_dir().join(format!("wa-7z-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
extract_7z_flat(archive, &dir).expect("7z extract");
let dll = dir.join("onnxruntime.dll");
assert!(dll.exists(), "onnxruntime.dll missing after extract");
assert_eq!(
std::fs::metadata(&dll).unwrap().len(),
17_253_408,
"unexpected DLL size — BCJ/LZMA2 decode may be wrong"
);
// Flattened: the archive's `directml/` prefix must be stripped.
assert!(
!dir.join("directml").exists(),
"folder prefix not flattened"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn segment(speaker: &str) -> TranscriptSegment {
TranscriptSegment {
id: 0,
start_ms: 0,
end_ms: 1000,
speaker: speaker.to_string(),
text: String::new(),
confidence: None,
interim: false,
}
}
#[test]
fn sync_backoff_grows_then_caps_at_one_hour() {
assert_eq!(sync_backoff_secs(0), 30);
assert_eq!(sync_backoff_secs(1), 60);
assert_eq!(sync_backoff_secs(2), 120);
// Monotonic non-decreasing and never above the 1h cap.
let mut prev = 0;
for a in 0..12 {
let b = sync_backoff_secs(a);
assert!(b >= prev && b <= 3600);
prev = b;
}
}
#[test]
fn speaker_infos_lists_distinct_speakers_in_first_appearance_order() {
let segments = vec![segment("S2"), segment("S1"), segment("S2")];
let infos = speaker_infos_from_segments(&segments, &HashMap::new());
let labels: Vec<&str> = infos.iter().map(|s| s.label.as_str()).collect();
assert_eq!(labels, vec!["S2", "S1"]);
}
#[test]
fn speaker_infos_applies_display_names_by_label() {
let segments = vec![segment("S1"), segment("S2")];
let mut names = HashMap::new();
names.insert("S1".to_string(), "Alice".to_string());
let infos = speaker_infos_from_segments(&segments, &names);
assert_eq!(infos[0].display_name.as_deref(), Some("Alice"));
assert_eq!(infos[1].display_name, None); // S2 was never named
}
#[test]
fn speaker_infos_falls_back_to_s1_placeholder_when_no_segments_yet() {
let infos = speaker_infos_from_segments(&[], &HashMap::new());
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].label, "S1");
assert!(infos[0].display_name.is_none());
}
#[test]
fn diarizer_is_none_when_models_are_not_installed() {
// This test environment never has the fixed-path diarization models
// installed (T4.7 will add real download/selection) — confirms the
// graceful-degradation path a recording never blocks on (T4.3).
assert!(diarizer_from_installed_models().is_none());
}
fn meeting_fixture() -> Meeting {
Meeting {
id: "m1".to_string(),
title: "Sprint planning".to_string(),
started_at: 0,
ended_at: None,
duration_secs: Some(1830), // 30.5 min
status: MeetingStatus::Ready,
recorded: false,
language: None,
backend_used: None,
model_used: None,
segments: Vec::new(),
speakers: vec![
SpeakerInfo {
label: "S1".to_string(),
display_name: Some("Alice".to_string()),
participant_id: None,
},
SpeakerInfo {
label: "S2".to_string(),
display_name: None,
participant_id: None,
},
],
notes_markdown: "**Alice:** Let's plan the sprint.".to_string(),
summary: None,
calendar_event_id: None,
tags: Vec::new(),
template_id: None,
}
}
#[test]
fn build_prompt_includes_title_duration_and_named_or_labeled_speakers() {
let prompt = build_prompt(&meeting_fixture(), None);
assert!(prompt.metadata.contains("Sprint planning"));
assert!(prompt.metadata.contains("30 min"));
assert!(prompt.metadata.contains("Alice"));
assert!(prompt.metadata.contains("S2")); // unnamed speaker falls back to its label
assert_eq!(prompt.transcript, "**Alice:** Let's plan the sprint.");
assert!(prompt.template.is_none());
}
#[test]
fn build_prompt_resolves_a_known_template_id_and_ignores_an_unknown_one() {
let known = build_prompt(&meeting_fixture(), Some("standup"));
assert!(known.template.unwrap().to_lowercase().contains("standup"));
let unknown = build_prompt(&meeting_fixture(), Some("no-such-template"));
assert!(unknown.template.is_none());
}
fn sync_target_fixture(
name: &str,
enabled: bool,
host: &str,
third_party: bool,
tls: bool,
) -> SyncTargetInfo {
SyncTargetInfo {
id: name.to_string(),
name: name.to_string(),
kind: SyncKind::WebDav,
provider_hint: Some("nextcloud".to_string()),
base_url: Some(format!("{}://{}", if tls { "https" } else { "http" }, host)),
remote_base_path: "/WhispAssist".to_string(),
username: Some("alice".to_string()),
enabled,
third_party,
host: Some(host.to_string()),
upload_transcript: true,
upload_notes: true,
upload_summary: true,
upload_recording: false,
trigger_on_finalize: true,
allow_plaintext_lan: false,
encrypt_before_upload: false,
}
}
#[test]
fn privacy_self_check_default_settings_is_fully_local_with_empty_allowlist() {
let result = privacy_self_check_json(&default_settings(), Vec::new());
assert_eq!(result["llmIsLocal"], true);
assert_eq!(result["llmEndpoint"], "http://localhost:11434");
assert_eq!(result["syncEnabled"], false);
assert_eq!(result["syncTargets"], serde_json::json!([]));
assert_eq!(result["allowlistedHosts"], serde_json::json!([]));
}
#[test]
fn privacy_self_check_remote_llm_endpoint_is_not_local_and_joins_the_allowlist() {
let mut settings = default_settings();
settings.llm_provider = "custom".to_string();
settings.llm_endpoint = "https://api.example.com".to_string();
let result = privacy_self_check_json(&settings, Vec::new());
assert_eq!(result["llmIsLocal"], false);
assert_eq!(
result["allowlistedHosts"],
serde_json::json!(["api.example.com"])
);
}
#[test]
fn privacy_self_check_only_enabled_sync_targets_join_the_allowlist() {
let mut settings = default_settings();
settings.sync_enabled = true;
let targets = vec![
sync_target_fixture("Nextcloud", true, "cloud.example.org", false, true),
sync_target_fixture(
"Old Disabled Target",
false,
"stale.example.org",
false,
true,
),
];
let result = privacy_self_check_json(&settings, targets);
assert_eq!(
result["allowlistedHosts"],
serde_json::json!(["cloud.example.org"])
);
assert_eq!(result["syncTargets"].as_array().unwrap().len(), 2);
assert_eq!(result["syncTargets"][0]["thirdParty"], false);
assert_eq!(result["syncTargets"][0]["tls"], true);
}
fn list_item(id: &str, title: &str) -> MeetingListItem {
MeetingListItem {
id: id.to_string(),
title: title.to_string(),
started_at: 0,
duration_secs: None,
status: MeetingStatus::Ready,
tags: Vec::new(),
}
}
#[test]
fn bulk_export_stem_sanitizes_unsafe_filename_characters() {
let item = list_item("abcdef12-0000-0000-0000-000000000000", "Q3: Sales / Ops?");
// ':',' ' -> "__"; ' ','/',' ' -> "___"; trailing '?' trimmed with the
// underscore it became, since trim_matches only strips leading/trailing.
assert_eq!(bulk_export_stem(&item), "Q3__Sales___Ops_abcdef12");
}
#[test]
fn bulk_export_stem_disambiguates_duplicate_titles_by_id() {
// Meeting titles are very often duplicates ("Untitled meeting") — the
// id prefix is what keeps a bulk export from overwriting files.
let a = list_item("11111111-aaaa", "Untitled meeting");
let b = list_item("22222222-bbbb", "Untitled meeting");
assert_ne!(bulk_export_stem(&a), bulk_export_stem(&b));
}
// ---- create_feature_brief_core (T10.6, M1.6 command-level test) ----
// `06-test-strategy.md` P10: LLM off/unreachable -> Err, nothing written
// to disk/DB. Exercised directly (not through the `#[tauri::command]`
// wrapper) with an in-memory store so it needs no live Tauri app.
#[tokio::test]
async fn create_feature_brief_core_errs_and_writes_nothing_when_llm_is_off() {
let store: std::sync::Arc<dyn crate::storage::Store> = std::sync::Arc::new(
crate::storage::SqliteStore::connect_in_memory()
.await
.unwrap(),
);
let mut settings = default_settings();
settings.llm_provider = "off".to_string();
let result =
create_feature_brief_core(&store, &"nonexistent".to_string(), None, &settings).await;
assert!(result.is_err());
assert!(store.list_feature_briefs(None).await.unwrap().is_empty());
}
#[tokio::test]
async fn create_feature_brief_core_errs_and_writes_nothing_when_llm_is_unreachable() {
let store: std::sync::Arc<dyn crate::storage::Store> = std::sync::Arc::new(
crate::storage::SqliteStore::connect_in_memory()
.await
.unwrap(),
);
let meeting_id = store
.create_meeting(NewMeeting {
title: "Test meeting".to_string(),
calendar_event_id: None,
template_id: None,
})
.await
.unwrap();
store
.finalize_meeting(
&meeting_id,
FinalizeMeeting {
segments: Vec::new(),
speakers: Vec::new(),
duration_secs: 60,
recorded: false,
language: None,
backend_used: None,
model_used: None,
},
)
.await
.unwrap();
let mut settings = default_settings();
settings.llm_provider = "ollama".to_string();
// Nothing listens here (loopback, not a real egress) — the LLM call
// fails fast with a connection error, same shape as "Ollama isn't
// running".
settings.llm_endpoint = "http://127.0.0.1:1".to_string();
let result = create_feature_brief_core(&store, &meeting_id, None, &settings).await;
assert!(result.is_err());
assert!(store
.list_feature_briefs(Some(&meeting_id))
.await
.unwrap()
.is_empty());
}
}