diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c3afe0ba..560bb72c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -38,6 +38,18 @@ updates: update-types: - "minor" - "patch" + ignore: + # Major bumps requiring dedicated migration effort (multi-crate, API changes) + - dependency-name: "rand" + update-types: ["version-update:semver-major"] + - dependency-name: "zip" + update-types: ["version-update:semver-major"] + - dependency-name: "rubato" + update-types: ["version-update:semver-major"] + - dependency-name: "sha2" + update-types: ["version-update:semver-major"] + - dependency-name: "rodio" + update-types: ["version-update:semver-major"] - package-ecosystem: "github-actions" directory: "/" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25532be1..ba5d8c8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,7 +108,7 @@ jobs: # matches FerriScribe version tags (vX.Y.Z); leaves whisper-server-* and # other non-version tags untouched. Runs once after all platform builds. prune: - needs: release + needs: [release, manifest] runs-on: ubuntu-24.04 permissions: contents: write @@ -120,3 +120,90 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: node scripts/prune-old-releases.mjs + + # Generate a consolidated latest.json AFTER all platform builds complete. + # Each platform job auto-generates its own partial latest.json via + # tauri-action; this job overwrites it with a manifest that includes ALL + # platforms, eliminating the updater race condition where clients see a + # new version before their platform binary is uploaded. + manifest: + needs: release + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Generate consolidated latest.json + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${GITHUB_REF#refs/tags/}" + VERSION="${TAG#v}" + echo "Generating consolidated latest.json for $TAG" + + # Get release metadata + PUB_DATE=$(gh release view "$TAG" --json createdAt -q .createdAt) + NOTES=$(gh release view "$TAG" --json body -q .body) + + # List all assets + ASSETS=$(gh release view "$TAG" --json assets -q '.assets[].name') + + # Generate latest.json using Node + node << 'SCRIPT' + const { execSync } = require('child_process'); + const fs = require('fs'); + + const tag = process.env.GITHUB_REF.replace('refs/tags/', ''); + const version = tag.replace(/^v/, ''); + const repo = 'cortexuvula/rustMedicalAssistant'; + const baseUrl = `https://github.com/${repo}/releases/download/${tag}`; + const pubDate = execSync(`gh release view ${tag} --json createdAt -q .createdAt`).toString().trim(); + const notes = execSync(`gh release view ${tag} --json body -q .body`).toString().trim(); + + const assetsJson = JSON.parse(execSync(`gh release view ${tag} --json assets`).toString()); + const assetNames = assetsJson.assets.map(a => a.name); + + const platforms = {}; + + for (const name of assetNames) { + if (!name.endsWith('.sig')) continue; + const baseAsset = name.replace(/\.sig$/, ''); + if (!assetNames.includes(baseAsset)) continue; + + // Read the signature content + const signature = execSync(`gh release download ${tag} --pattern "${name}" --output - 2>/dev/null`).toString().trim(); + + // Map filename to Tauri platform key + let platform = null; + if (name.includes('.deb') || name.includes('.rpm')) platform = 'linux-x86_64'; + else if (name.includes('.msi') || name.includes('-setup.exe')) platform = 'windows-x86_64'; + else if (name.includes('aarch64.app')) platform = 'darwin-aarch64'; + else if (name.includes('x86_64.app')) platform = 'darwin-x86_64'; + else continue; + + // Only keep the NSIS updater format for Windows (not MSI) + if (name.includes('.msi') && assetNames.some(n => n.includes('-setup.exe'))) continue; + + platforms[platform] = { + signature, + url: `${baseUrl}/${baseAsset}`, + }; + } + + const manifest = { + version, + notes, + pub_date: pubDate, + platforms, + }; + + console.log('Generated manifest:', JSON.stringify(manifest, null, 2)); + fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2)); + SCRIPT + + - name: Upload consolidated latest.json + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${GITHUB_REF#refs/tags/}" + gh release upload "$TAG" latest.json --clobber + echo "Consolidated latest.json uploaded to release $TAG" diff --git a/Cargo.lock b/Cargo.lock index 0489bad1..6e63f2a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,6 +276,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-task" version = "4.7.1" @@ -5002,8 +5024,9 @@ dependencies = [ [[package]] name = "rust-medical-assistant" -version = "0.28.7" +version = "0.28.8" dependencies = [ + "async-stream", "axum", "chrono", "dirs", diff --git a/crates/db/src/migrations/m012_encryption_pending.rs b/crates/db/src/migrations/m012_encryption_pending.rs new file mode 100644 index 00000000..fea82fd3 --- /dev/null +++ b/crates/db/src/migrations/m012_encryption_pending.rs @@ -0,0 +1,16 @@ +use rusqlite::Connection; + +use crate::DbResult; + +/// Add `encryption_pending` column to `recordings`. +/// +/// Tracks whether a recording's WAV file is still being encrypted by a +/// background task spawned from `stop_recording`. A startup sweep reads +/// rows left at `1` (the app crashed or was hard-quit mid-encryption) and +/// encrypts them so no plaintext audio remains at rest. +pub fn up(conn: &Connection) -> DbResult<()> { + conn.execute_batch( + "ALTER TABLE recordings ADD COLUMN encryption_pending INTEGER NOT NULL DEFAULT 0;", + )?; + Ok(()) +} diff --git a/crates/db/src/migrations/mod.rs b/crates/db/src/migrations/mod.rs index 54e8077b..73683d20 100644 --- a/crates/db/src/migrations/mod.rs +++ b/crates/db/src/migrations/mod.rs @@ -15,6 +15,7 @@ pub mod m008_peer_discussion; pub mod m009_soft_delete; pub mod m010_condition_chips; pub mod m011_condition_chip_order; +pub mod m012_encryption_pending; use rusqlite::Connection; @@ -94,6 +95,11 @@ pub fn all_migrations() -> &'static [Migration] { name: "condition_chip_order", up: m011_condition_chip_order::up, }, + Migration { + version: 12, + name: "encryption_pending", + up: m012_encryption_pending::up, + }, ] } diff --git a/crates/db/src/recordings.rs b/crates/db/src/recordings.rs index ad991c89..9c403a84 100644 --- a/crates/db/src/recordings.rs +++ b/crates/db/src/recordings.rs @@ -301,6 +301,45 @@ impl RecordingsRepo { Ok(updated as u32) } + /// Mark a recording's background encryption as complete (clears the + /// `encryption_pending` flag set by `stop_recording`). + /// + /// Called from the `spawn_blocking` task that encrypts the WAV file, on + /// both success and failure — the flag is "encryption attempt finished", + /// not "encryption succeeded". On failure the recording is left as + /// plaintext at rest, which the reader (`open_recording_wav`) handles + /// transparently. + pub fn set_encryption_done(conn: &Connection, id: &Uuid) -> DbResult<()> { + conn.execute( + "UPDATE recordings SET encryption_pending = 0 WHERE id = ?1", + [&id.to_string()], + )?; + Ok(()) + } + + /// Return the `(id, audio_path)` of every recording still flagged + /// `encryption_pending = 1`. + /// + /// Used by the startup sweep to encrypt WAVs left plaintext by a crash + /// or hard-quit mid-encryption. Rows with a missing/unparseable id or + /// path are dropped (they can't be encrypted by name). + pub fn list_encryption_pending(conn: &Connection) -> DbResult> { + let mut stmt = + conn.prepare("SELECT id, audio_path FROM recordings WHERE encryption_pending = 1")?; + let rows = stmt + .query_map([], |row| { + let id_str: String = row.get(0)?; + let path: String = row.get(1)?; + Ok(( + Uuid::parse_str(&id_str).unwrap_or_else(|_| Uuid::nil()), + PathBuf::from(path), + )) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok(rows) + } + /// Fetch multiple recordings by ID in a single query. /// /// Order is not guaranteed; sort the result on the caller side if needed. diff --git a/crates/db/tests/condition_chips_sync.rs b/crates/db/tests/condition_chips_sync.rs new file mode 100644 index 00000000..22123db3 --- /dev/null +++ b/crates/db/tests/condition_chips_sync.rs @@ -0,0 +1,158 @@ +//! Integration test: condition chip sync round-trip between two independent +//! databases (simulating two machines). + +use medical_core::types::condition_chip::{ConditionChip, deterministic_id}; +use medical_db::Database; +use medical_db::condition_chips::ConditionChipsRepo; + +/// ISO 8601 timestamp offset from a fixed base epoch. +fn now(offset_secs: i64) -> String { + let base = chrono::DateTime::parse_from_rfc3339("2026-07-08T10:00:00Z").unwrap(); + let t = base + chrono::Duration::seconds(offset_secs); + t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() +} + +/// Simulate a sync round-trip: A pushes its full list, server (B) merges, +/// returns active list, A merges the result back. +/// +/// Both sides push `list_all` (including tombstones) so tombstones propagate. +fn sync_roundtrip( + conn_a: &rusqlite::Connection, + conn_b: &rusqlite::Connection, +) -> (Vec, Vec) { + let a_all = ConditionChipsRepo::list_all(conn_a).unwrap(); + let _b_result = ConditionChipsRepo::merge_incoming(conn_b, &a_all).unwrap(); + let b_all = ConditionChipsRepo::list_all(conn_b).unwrap(); + let a_result = ConditionChipsRepo::merge_incoming(conn_a, &b_all).unwrap(); + let b_result = ConditionChipsRepo::list_active(conn_b).unwrap(); + (a_result, b_result) +} + +/// Collect the active chip texts in their displayed sort order. +fn texts(chips: &[ConditionChip]) -> Vec { + chips.iter().map(|c| c.text.clone()).collect() +} + +#[test] +fn both_add_unique_chips_converge() { + // A adds "Hypertension", B adds "Diabetes". After a round-trip both + // machines must hold both chips. + let db_a = Database::open_in_memory().unwrap(); + let db_b = Database::open_in_memory().unwrap(); + let conn_a = db_a.conn().unwrap(); + let conn_b = db_b.conn().unwrap(); + + ConditionChipsRepo::add(&conn_a, "Hypertension", &now(0)).unwrap(); + ConditionChipsRepo::add(&conn_b, "Diabetes", &now(0)).unwrap(); + + let (a_after, b_after) = sync_roundtrip(&conn_a, &conn_b); + + assert_eq!(a_after.len(), 2, "A should have both chips"); + assert_eq!(b_after.len(), 2, "B should have both chips"); + + let mut a_texts = texts(&a_after); + let mut b_texts = texts(&b_after); + a_texts.sort(); + b_texts.sort(); + assert_eq!( + a_texts, + vec!["Diabetes".to_string(), "Hypertension".to_string()] + ); + assert_eq!( + b_texts, + vec!["Diabetes".to_string(), "Hypertension".to_string()] + ); +} + +#[test] +fn both_add_same_chip_converge_to_one() { + // A adds "Asthma" at t=0, B adds "Asthma" at t=1. Both produce the same + // deterministic id, so after a round-trip there must be exactly one + // Asthma chip on each side (no duplicate). + let db_a = Database::open_in_memory().unwrap(); + let db_b = Database::open_in_memory().unwrap(); + let conn_a = db_a.conn().unwrap(); + let conn_b = db_b.conn().unwrap(); + + ConditionChipsRepo::add(&conn_a, "Asthma", &now(0)).unwrap(); + ConditionChipsRepo::add(&conn_b, "Asthma", &now(1)).unwrap(); + + let (a_after, b_after) = sync_roundtrip(&conn_a, &conn_b); + + assert_eq!(a_after.len(), 1, "A should have exactly one Asthma chip"); + assert_eq!(b_after.len(), 1, "B should have exactly one Asthma chip"); + assert_eq!(a_after[0].text, "Asthma"); + assert_eq!(b_after[0].text, "Asthma"); + // The newer timestamp (t=1) should have won on both sides. + assert_eq!(a_after[0].updated_at, now(1)); + assert_eq!(b_after[0].updated_at, now(1)); +} + +#[test] +fn tombstone_propagates_across_roundtrip() { + // Both start with "COPD". A removes it (tombstone at t=10). After a + // round-trip both sides must have zero active chips. + let db_a = Database::open_in_memory().unwrap(); + let db_b = Database::open_in_memory().unwrap(); + let conn_a = db_a.conn().unwrap(); + let conn_b = db_b.conn().unwrap(); + + ConditionChipsRepo::add(&conn_a, "COPD", &now(0)).unwrap(); + ConditionChipsRepo::add(&conn_b, "COPD", &now(0)).unwrap(); + + // A soft-deletes COPD at t=10. + ConditionChipsRepo::remove_by_text(&conn_a, "COPD", &now(10)).unwrap(); + + let (a_after, b_after) = sync_roundtrip(&conn_a, &conn_b); + + assert!(a_after.is_empty(), "A should have no active chips"); + assert!( + b_after.is_empty(), + "B should have no active chips (tombstone propagated)" + ); + + // The tombstone itself should be present in list_all on both sides. + let a_all = ConditionChipsRepo::list_all(&conn_a).unwrap(); + let b_all = ConditionChipsRepo::list_all(&conn_b).unwrap(); + assert_eq!(a_all.len(), 1, "A should retain the tombstone"); + assert_eq!(b_all.len(), 1, "B should retain the tombstone"); + assert!(a_all[0].deleted_at.is_some(), "A row must be a tombstone"); + assert!(b_all[0].deleted_at.is_some(), "B row must be a tombstone"); +} + +#[test] +fn reorder_propagates_across_roundtrip() { + // Both start with Alpha, Beta. Sync the initial state so B has both. + // A then reorders to [Beta, Alpha]. After a round-trip both sides must + // display [Beta, Alpha]. + let db_a = Database::open_in_memory().unwrap(); + let db_b = Database::open_in_memory().unwrap(); + let conn_a = db_a.conn().unwrap(); + let conn_b = db_b.conn().unwrap(); + + ConditionChipsRepo::add(&conn_a, "Alpha", &now(0)).unwrap(); + ConditionChipsRepo::add(&conn_a, "Beta", &now(0)).unwrap(); + ConditionChipsRepo::add(&conn_b, "Alpha", &now(0)).unwrap(); + ConditionChipsRepo::add(&conn_b, "Beta", &now(0)).unwrap(); + + // Sync initial state so both converge on the same ordering. + let _ = sync_roundtrip(&conn_a, &conn_b); + + // A reorders to [Beta, Alpha] at t=10. + let beta_id = deterministic_id("Beta"); + let alpha_id = deterministic_id("Alpha"); + ConditionChipsRepo::reorder(&conn_a, &[beta_id, alpha_id], &now(10)).unwrap(); + + let (a_after, b_after) = sync_roundtrip(&conn_a, &conn_b); + + assert_eq!( + texts(&a_after), + vec!["Beta".to_string(), "Alpha".to_string()], + "A should display [Beta, Alpha]" + ); + assert_eq!( + texts(&b_after), + vec!["Beta".to_string(), "Alpha".to_string()], + "B should display [Beta, Alpha] after reorder propagates" + ); +} diff --git a/docs/superpowers/plans/2026-07-08-item1-sse-chip-sync.md b/docs/superpowers/plans/2026-07-08-item1-sse-chip-sync.md new file mode 100644 index 00000000..bbf160c0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-item1-sse-chip-sync.md @@ -0,0 +1,404 @@ +# Item #1: SSE Realtime Chip Sync — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Replace the 30s poll with push-based SSE so chip changes appear within seconds across machines. + +**Architecture:** Add a `tokio::sync::broadcast` channel to the server's `ApiState`. When the sync handler completes a merge, broadcast a notification. Add an SSE endpoint that streams these notifications. The client subscribes via a long-lived streaming reqwest connection and emits a Tauri event the frontend listens for. Keep the 30s poll as a safety net. + +**Tech Stack:** Axum 0.8 SSE, reqwest streaming, tokio broadcast, Tauri events. + +**Spec:** `docs/superpowers/specs/2026-07-08-high-priority-improvements-design.md` (Item #1) + +--- + +## File Structure + +### Modified files + +| File | Change | +|------|--------| +| `src-tauri/src/sharing_vocab_api.rs` | Add broadcast channel to ApiState, SSE endpoint, trigger on sync | +| `src-tauri/src/conditions_remote.rs` | Add `subscribe_events()` streaming method | +| `src-tauri/src/commands/conditions.rs` | Add `subscribe_condition_chips` command | +| `src-tauri/src/lib.rs` | Register new command | +| `src/lib/components/ConditionChips.svelte` | Listen for `condition-chips-changed` event | + +--- + +## Task 1: Add broadcast channel to server ApiState + SSE endpoint + +**Files:** +- Modify: `src-tauri/src/sharing_vocab_api.rs` + +- [ ] **Step 1: Read the current file** + +Read `src-tauri/src/sharing_vocab_api.rs` fully. Find: +- The `ApiState` struct definition +- The `spawn` function that builds the Router +- The `condition_chips_sync_handler` function +- The imports section + +- [ ] **Step 2: Add broadcast channel to ApiState** + +Add a broadcast sender to `ApiState`. The channel carries `()` — just a "something changed" signal, no data: + +```rust +use tokio::sync::broadcast; + +#[derive(Clone)] +pub struct ApiState { + db: Arc, + tokens: Arc, + chips_changed_tx: broadcast::Sender<()>, +} +``` + +In the `spawn` function, create the channel before building `ApiState`: + +```rust +let (chips_changed_tx, _) = broadcast::channel::<()>(16); +let state = ApiState { + db: Arc::clone(&db), + tokens: Arc::clone(&tokens), + chips_changed_tx: chips_changed_tx.clone(), +}; +``` + +- [ ] **Step 3: Trigger broadcast in sync handler** + +In `condition_chips_sync_handler`, after the merge succeeds and before returning, trigger the broadcast: + +```rust + // Notify SSE subscribers that chips changed. + let _ = state.chips_changed_tx.send(()); +``` + +This is best-effort — `send` returns Err if there are no receivers, which is fine (no subscribers = no notification needed). + +- [ ] **Step 4: Add the SSE endpoint** + +Add a new route to the Router (alongside the other condition-chips routes): + +```rust + .route("/v1/condition-chips/events", get(condition_chips_events_handler)) +``` + +Add the handler: + +```rust +use axum::response::sse::{Event, Sse}; +use futures_util::Stream; + +/// GET /v1/condition-chips/events — SSE stream of chip-change notifications. +/// +/// Sends a "changed" event whenever the sync handler completes a merge. +/// The client uses this as a "re-pull" signal — it calls list_condition_chips +/// on each event. +async fn condition_chips_events_handler( + AxumState(state): AxumState, + headers: HeaderMap, +) -> Result>>, StatusCode> { + let _ = authorize(&state, &headers)?; + let mut rx = state.chips_changed_tx.subscribe(); + + let stream = async_stream::stream! { + // Send an initial event so the client knows the connection is live. + yield Ok(Event::data("connected")); + loop { + match rx.recv().await { + Ok(()) => yield Ok(Event::data("changed")), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + } + } + }; + + Ok(Sse::new(stream)) +} +``` + +**IMPORTANT:** This uses `async_stream` and `futures_util`. Check if they're already dependencies. If not, add them to `src-tauri/Cargo.toml`: +- `async-stream` or `async-stream` — check workspace deps +- `futures-util` — likely already available + +Read `src-tauri/Cargo.toml` to check. The `axum` SSE support is built-in (no extra feature flag needed for 0.8). + +- [ ] **Step 5: Build to verify** + +Run: `cargo build -p rust-medical-assistant 2>&1 | tail -20` +Expected: compiles. Fix any missing imports or dependencies. + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/src/sharing_vocab_api.rs src-tauri/Cargo.toml +git commit -m "feat(sharing): SSE endpoint for condition chip change notifications" +``` + +--- + +## Task 2: Add SSE subscription client + Tauri command + +**Files:** +- Modify: `src-tauri/src/conditions_remote.rs` +- Modify: `src-tauri/src/commands/conditions.rs` +- Modify: `src-tauri/src/lib.rs` + +- [ ] **Step 1: Add subscribe_events to ConditionsRemote** + +Read `src-tauri/src/conditions_remote.rs` fully. Add a new method that opens a streaming GET and yields "changed" signals: + +```rust +use futures_util::StreamExt; + +/// Subscribe to SSE change notifications. Returns a stream of () items, +/// each representing a "chips changed" signal from the server. +/// +/// The caller should call `list_condition_chips` (pull + merge) on each +/// item to stay in sync. +pub async fn subscribe_events( + &self, +) -> AppResult> { + let url = format!( + "{}/v1/condition-chips/events", + self.base_url().ok_or_else(|| { + AppError::Other("no vocab base URL for conditions remote".into()) + })? + ); + + let resp = self + .client + .get(&url) + .timeout(Duration::from_secs(300)) // long timeout for SSE + .bearer_auth(&self.bearer) + .send() + .await + .map_err(|e| AppError::Other(format!("SSE connect: {e}")))?; + + if !resp.status().is_success() { + return Err(AppError::Other(format!( + "SSE connect failed: {}", + resp.status() + ))); + } + + // Parse SSE lines from the response body stream. + let byte_stream = resp.bytes_stream(); + let event_stream = byte_stream + .filter_map(|chunk| async move { + match chunk { + Ok(bytes) => { + let text = String::from_utf8_lossy(&bytes); + // SSE events are "data: \n\n" + for line in text.lines() { + if line.starts_with("data: changed") { + return Some(()); + } + } + None + } + Err(_) => None, + } + }); + + Ok(event_stream) +} +``` + +**Note:** The SSE parsing is intentionally simple — we only look for `data: changed` lines. The `data: connected` initial event is ignored (it's just a connection acknowledgment). + +- [ ] **Step 2: Add the subscribe command** + +In `src-tauri/src/commands/conditions.rs`, add a command that starts the SSE subscription and emits a Tauri event on each notification: + +```rust +/// Start listening for condition chip change notifications from the server. +/// Emits a `condition-chips-changed` Tauri event whenever the server notifies +/// of a change. The frontend calls this when sync is enabled + paired. +#[tauri::command] +#[instrument(skip(app, state), name = "conditions::subscribe")] +pub async fn subscribe_condition_chips( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> AppResult<()> { + // Only subscribe if sync is enabled + paired. + let Some((conn, bearer)) = paired_conditions_target(&state) else { + return Ok(()); + }; + let Some(remote) = crate::conditions_remote::ConditionsRemote::from( + &conn, + Some(bearer), + state.http_client.clone(), + ) else { + return Ok(()); + }; + + // Clone the connection data for the spawned task (avoids borrow issues). + // The remote borrows conn — we need to move conn into the task. + // Actually, ConditionsRemote borrows &PairedConnection which can't be moved + // into a 'static task. We need to reconstruct inside the task. + let http_client = state.http_client.clone(); + let bearer = bearer; + let conn = conn; + + tokio::spawn(async move { + let mut backoff = Duration::from_secs(5); + loop { + let remote = match crate::conditions_remote::ConditionsRemote::from( + &conn, + Some(bearer.clone()), + http_client.clone(), + ) { + Some(r) => r, + None => { + tracing::warn!("SSE subscription: remote unavailable, retrying"); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(Duration::from_secs(30)); + continue; + } + }; + + match remote.subscribe_events().await { + Ok(mut stream) => { + tracing::info!("SSE subscription connected"); + backoff = Duration::from_secs(5); // reset backoff on success + while let Some(()) = stream.next().await { + // Emit Tauri event — frontend listens for this. + let _ = app.emit("condition-chips-changed", ()); + } + tracing::info!("SSE stream ended, reconnecting"); + } + Err(e) => { + tracing::warn!(error = %e, "SSE subscription failed, reconnecting"); + } + } + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(Duration::from_secs(30)); + } + }); + + Ok(()) +} +``` + +**IMPORTANT:** This command spawns an infinite reconnection loop. The task runs forever (until the app exits). The `app.emit("condition-chips-changed", ())` is what the frontend listens for. + +Add necessary imports at the top of `conditions.rs`: +```rust +use std::time::Duration; +use futures_util::StreamExt; +use tauri::Emitter; +``` + +- [ ] **Step 3: Register the command** + +In `src-tauri/src/lib.rs`, add to `generate_handler!`: +```rust + commands::conditions::subscribe_condition_chips, +``` + +- [ ] **Step 4: Build to verify** + +Run: `cargo build -p rust-medical-assistant 2>&1 | tail -20` +Expected: compiles. Fix any issues. + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/conditions_remote.rs src-tauri/src/commands/conditions.rs src-tauri/src/lib.rs src-tauri/Cargo.toml +git commit -m "feat(sync): SSE client + subscribe command for realtime chip sync" +``` + +--- + +## Task 3: Frontend — listen for SSE events + call subscribe on mount + +**Files:** +- Modify: `src/lib/components/ConditionChips.svelte` + +- [ ] **Step 1: Add Tauri event listener** + +In `ConditionChips.svelte`, import the Tauri event listener: + +```typescript + import { listen } from '@tauri-apps/api/event'; +``` + +In `onMount`, after starting the poll, also start the SSE subscription and listen for the event: + +```typescript + onMount(async () => { + await refreshChips(); + pollHandle = setInterval(refreshChips, 30_000); + + // Listen for SSE push notifications (realtime sync). + unlistenSSE = await listen('condition-chips-changed', () => { + refreshChips(); + }); + + // Start the SSE subscription on the backend (no-op if not paired). + try { + await invoke('subscribe_condition_chips'); + } catch (e) { + console.error('Failed to start chip sync subscription:', e); + } + }); +``` + +Add the state + cleanup: + +```typescript + let unlistenSSE: (() => void) | null = null; +``` + +In `onDestroy`: +```typescript + onDestroy(() => { + if (pollHandle) clearInterval(pollHandle); + if (dirtyTimer) clearTimeout(dirtyTimer); + if (unlistenSSE) unlistenSSE(); + }); +``` + +Add the `invoke` import if not already present: +```typescript + import { invoke } from '@tauri-apps/api/core'; +``` + +- [ ] **Step 2: Run type check** + +Run: `npm run check` +Expected: 0 errors. + +- [ ] **Step 3: Run lint + tests** + +Run: `npx eslint src/lib/components/ConditionChips.svelte` +Run: `npx vitest run src/lib/components/ConditionChips.test.ts` +Expected: 0 errors, all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/components/ConditionChips.svelte +git commit -m "feat(frontend): listen for SSE chip change events for realtime sync" +``` + +--- + +## Self-Review + +### Spec coverage +- ✅ Broadcast channel in ApiState — Task 1 +- ✅ SSE endpoint — Task 1 +- ✅ Trigger on sync handler — Task 1 +- ✅ Client subscribe_events() — Task 2 +- ✅ Tauri command + reconnection loop — Task 2 +- ✅ Frontend listen + subscribe on mount — Task 3 +- ✅ Keep 30s poll as safety net — Task 3 (pollHandle unchanged) + +### Known caveats +1. `async_stream` and `futures_util` may need to be added to Cargo.toml — check in Task 1 Step 4. +2. The `PairedConnection` borrow issue in the spawned task — the command reconstructs `ConditionsRemote` inside the loop to avoid lifetime issues. +3. The SSE stream parsing is simple line-matching — doesn't handle multi-line SSE data fields, but our events are single-line (`data: changed`). +4. The `subscribe_condition_chips` command is fire-and-forget — it spawns a forever-loop task. There's no explicit stop mechanism beyond app exit. This is acceptable since the task is cheap and the poll is the safety net. diff --git a/docs/superpowers/plans/2026-07-08-item2-conflict-toasts.md b/docs/superpowers/plans/2026-07-08-item2-conflict-toasts.md new file mode 100644 index 00000000..7243441e --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-item2-conflict-toasts.md @@ -0,0 +1,149 @@ +# Item #2: Conflict Feedback Toasts — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Show a toast when another machine changes condition chips while the user has local pending changes. + +**Architecture:** Track a `dirtySince` timestamp in `ConditionChips.svelte`. Set it on local mutations (add/remove/reorder). When the poll detects a remote change AND `dirtySince` is within 5 seconds, show an info toast. Clear `dirtySince` after 5s. + +**Tech Stack:** Svelte 5 runes, existing `toasts` store. + +**Spec:** `docs/superpowers/specs/2026-07-08-high-priority-improvements-design.md` (Item #2) + +--- + +## Task 1: Add dirtySince tracking + toast on remote change + +**Files:** +- Modify: `src/lib/components/ConditionChips.svelte` + +- [ ] **Step 1: Read the current component** + +Read `src/lib/components/ConditionChips.svelte` fully. Find: +- The `refreshChips` function (the poll callback) +- The `addNewCondition`, `removeCondition`, and `handleDrop` functions (local mutations) +- The imports at the top + +- [ ] **Step 2: Add imports + dirtySince state** + +In the ` + +
+ + + +
+ +{#if sharingOn} +

Stop sharing first (in the panel below) before switching modes.

+{/if} + + +``` + +- [ ] **Step 3: Create ConditionChipSync.svelte** + +Extract the sync toggle: + +```svelte + + +{#if visible} + +{/if} +``` + +- [ ] **Step 4: Rewrite Sharing.svelte as thin orchestrator** + +Replace Sharing.svelte with: + +```svelte + + + + + +``` + +**IMPORTANT:** The `onStatusChange` callback replaces the direct invoke calls that were in Sharing.svelte. The ServerWizard/ServerStatus components previously used an `ondone`/`onstopped` callback prop for refresh — check if those are still needed and wire them through the modes component. Read the original Sharing.svelte to verify the callback wiring for ServerWizard and ServerStatus. + +- [ ] **Step 5: Run type check + lint + tests** + +Run: `npm run check` +Run: `npx eslint src/lib/components/settings/Sharing.svelte src/lib/components/settings/sharing/SharingModes.svelte src/lib/components/settings/sharing/ConditionChipSync.svelte` +Expected: 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/components/settings/Sharing.svelte src/lib/components/settings/sharing/SharingModes.svelte src/lib/components/settings/sharing/ConditionChipSync.svelte +git commit -m "refactor: split Sharing.svelte into SharingModes + ConditionChipSync sections + +Follows the General.svelte split pattern. Parent is now a thin orchestrator +(~25 lines) that routes sub-components based on mode." +``` + +--- + +## Self-Review + +### Spec coverage +- ✅ #5 join_err helper — Task 1 (revised to avoid medical-core dep change) +- ✅ #6 Round-trip test — Task 1 (4 test scenarios) +- ✅ #7 Dependabot triage — Tasks 1-2 (close + ignore + try-merge) +- ✅ #8 Sharing split — Task 1 (2 new components + thin parent) + +### Type consistency +- `join_err(e: tokio::task::JoinError) -> AppError` — consistent signature +- `SharingModes` callbacks: `onModeChange(mode)` + `onStatusChange(sharingOn, pairedTo)` — must match parent's usage +- `ConditionChipSync` prop: `visible: boolean` — matches parent's `sharingOn || pairedTo !== null` + +### Known caveats +1. #5: The `join_err` helper avoids adding tokio to medical-core. Some command files may import `AppError` directly and need `use crate::commands::join_err;` added. +2. #8: ServerWizard/ServerStatus previously had `ondone`/`onstopped` callbacks for refresh — these need to be wired through SharingModes. Verify the original Sharing.svelte callback wiring. +3. #7: sha2 and rodio bumps may not compile — have a close-with-comment plan ready. diff --git a/docs/superpowers/specs/2026-07-08-high-priority-improvements-design.md b/docs/superpowers/specs/2026-07-08-high-priority-improvements-design.md new file mode 100644 index 00000000..e1683611 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-high-priority-improvements-design.md @@ -0,0 +1,163 @@ +# High-Priority Improvements — Design Spec + +**Date:** 2026-07-08 +**Status:** Approved (all design sections) +**Scope:** 4 independent improvements, implemented sequentially + +## Overview + +Four high-priority improvements identified during a review session. Each is an independent subsystem with its own design, implemented in risk order: #2 (toasts) → #3 (release fix) → #1 (SSE) → #4 (async encryption). + +--- + +## Item #2: Conflict Feedback Toasts + +### Problem +When the 30s poll detects that another machine reordered/changed chips, `refreshChips` silently overwrites local state. If the user just reordered locally, their change is clobbered with no indication. + +### Approach +Track local mutations via a `dirtySince` timestamp. When the poll fires: +- **No local mutation in the last 5 seconds** → silent update (normal idle case) +- **Local mutation happened recently** → show a toast: "Condition chips updated from another machine" (auto-dismiss, info-level) + +### Details +- `dirtySince` is a `$state` in `ConditionChips.svelte`, set on every local add/remove/reorder, cleared after 5s via `setTimeout` +- The toast uses the existing `toasts.add()` API with `autoDismiss: true` +- No action button needed — chips already updated via LWW, this is just awareness +- The toast is shown at most once per detected change (not on every poll tick) + +### Files +- `src/lib/components/ConditionChips.svelte` — add `dirtySince` state, set it in add/remove/reorder handlers, check it in `refreshChips` +- `src/lib/stores/toasts.svelte.ts` — already has `add()` API, no changes needed + +--- + +## Item #3: latest.json Release Order Fix + +### Problem +The release workflow runs 3 parallel matrix jobs (Linux, Windows, macOS). Each uses `tauri-action` which auto-generates `latest.json` independently. The first job to finish publishes a partial manifest — clients see a new version but can't download their platform's binary yet. + +### Approach +1. Set `updaterJson: false` on the `tauri-action` step to suppress per-job `latest.json` generation +2. Add a `manifest` job with `needs: release` (same pattern as the existing `prune` job) that runs after all platform builds complete +3. The manifest job generates a consolidated `latest.json` with all platform entries and uploads it to the release + +### Manifest generation +A small inline Node.js script reads the release assets via `gh` CLI, finds the platform-specific assets + their `.sig` files, and constructs the JSON matching Tauri's expected format: + +```json +{ + "version": "0.29.0", + "notes": "...", + "pub_date": "2026-07-08T...", + "platforms": { + "linux-x86_64": { "signature": "...", "url": "..." }, + "windows-x86_64": { "signature": "...", "url": "..." }, + "darwin-aarch64": { "signature": "...", "url": "..." } + } +} +``` + +### Files +- `.github/workflows/release.yml` — add `updaterJson: false`, add `manifest` job + +### Why this eliminates the race +`latest.json` only appears after every platform binary + signature is uploaded. Clients never see a partial manifest. The updater store's friendly error message (from v0.26.2) remains as a safety net. + +--- + +## Item #1: SSE Realtime Chip Sync + +### Problem +The 30s poll means up to 30 seconds of latency between machine A reordering and machine B seeing it. + +### Approach +Add SSE (Server-Sent Events) push from the vocab API server. The client subscribes to a stream and re-pulls immediately when notified. + +### Server side (`sharing_vocab_api.rs`) +- New endpoint: `GET /v1/condition-chips/events` — returns `text/event-stream` +- Add `tokio::sync::broadcast::Sender<()>` to `ApiState` +- When `condition_chips_sync_handler` completes a merge, call `sender.send(())` to notify subscribers +- The SSE handler receives from the broadcast channel and sends `data: changed\n\n` +- Client doesn't need chip data in the SSE event — just a "re-pull" signal + +### Client side (`conditions_remote.rs` + background task) +- New `subscribe_events()` method on `ConditionsRemote` using `reqwest`'s `resp.bytes_stream()` for SSE line parsing +- A long-lived `tokio::spawn` task reads the stream and emits Tauri event `condition-chips-changed` +- Frontend listens via `listen('condition-chips-changed', ...)` and calls `refreshChips()` immediately + +### Connection lifecycle +- The SSE subscription starts when the condition chip component mounts (same time as the initial pull + poll). It only connects if sync is enabled AND paired. +- On unpair/disconnect: the streaming response errors out naturally, the task logs and enters backoff +- On network drop: reqwest stream errors → task logs, waits 5s (exponential backoff up to 30s), reconnects +- On component destroy: the task is cancelled (drop the JoinHandle) +- **Keep the 30s poll as safety net** — if SSE works, the poll is a no-op (no changes detected). Belt and suspenders. + +### Why SSE over WebSocket +SSE is simpler: unidirectional server→client, plain HTTP, no framing protocol. Chip sync only needs server→client push; the client already pushes via HTTP POST. + +### Files +- `src-tauri/src/sharing_vocab_api.rs` — add broadcast channel to ApiState, SSE endpoint, trigger on sync +- `src-tauri/src/conditions_remote.rs` — add `subscribe_events()` streaming method +- `src-tauri/src/commands/conditions.rs` — add command to start/stop SSE subscription +- `src/lib/components/ConditionChips.svelte` — listen for `condition-chips-changed` event + +--- + +## Item #4: Async Encryption (Non-Blocking Stop) + +### Problem +`stop_recording` blocks for 1-2 seconds while encrypting the WAV (AES-256-GCM + fsync on a 30-50MB file). The user can't start a new recording until it returns. + +### Critical constraint +The transcription pipeline reads the WAV immediately after `stop_recording` returns. If encryption is mid-rename, the reader sees a half-written file ("no RIFF tag found"). The current await exists to prevent this race. + +### Approach — background encryption with reader-coordination flag +1. `stop_recording` saves the WAV, inserts the recording row with `encryption_pending = true`, spawns background encryption task, and returns immediately (~50ms) +2. Background task calls `encrypt_file_in_place`, then updates the row: `encryption_pending = false` +3. The reader (`open_recording_wav`) already handles both encrypted and plaintext files — it checks `FE1` magic and branches accordingly + +### Why this is safe +Encryption is atomic at the filesystem level (rename is atomic on all platforms). The reader checks for `FE1` magic: +- Before rename: file is plaintext → reader gets `NotEncrypted` → reads plaintext ✓ +- After rename: file is ciphertext → reader decrypts ✓ +- No window where reader sees a corrupted file — rename either completed or it didn't + +### New DB column (migration m012) +`encryption_pending BOOLEAN NOT NULL DEFAULT 0` on the `recordings` table. Tracks encryption state independently from the recording's processing `status`. + +### Background task reliability +If the app crashes between insert and encryption, the recording stays plaintext with `encryption_pending = true`. On next app launch, a sweep checks for `encryption_pending = true` rows and encrypts them (same pattern as `fail_stuck_processing`). + +### Files +- `crates/db/src/migrations/m012_encryption_pending.rs` — add column +- `crates/db/src/migrations/mod.rs` — register m012 +- `crates/db/src/recordings.rs` — add `set_encryption_pending` method +- `src-tauri/src/commands/audio.rs` — change `stop_recording` to spawn background encryption instead of awaiting +- `src-tauri/src/lib.rs` or `src-tauri/src/state.rs` — add encryption sweep on startup + +### Implementation order +This item is done LAST (after #2, #3, #1) because it touches the most sensitive code path (audio recording + PHI encryption). + +--- + +## Testing Strategy + +### Item #2 (toasts) +- Unit test: `dirtySince` is set on add/remove/reorder, cleared after 5s +- Frontend test: toast appears when poll detects change while dirty, no toast when idle + +### Item #3 (release) +- Verify the manifest job produces valid JSON with all 3 platforms +- Manual: trigger a release tag, verify `latest.json` appears only after all assets + +### Item #1 (SSE) +- Server unit test: broadcast sender triggers on sync handler +- Client integration test: SSE stream receives "changed" event after a sync +- Connection lifecycle: test reconnection after simulated network drop + +### Item #4 (async encryption) +- DB migration test: m012 adds column without data loss +- Reader test: `open_recording_wav` correctly reads both `encryption_pending=true` (plaintext) and `encryption_pending=false` (ciphertext) files +- Integration test: `stop_recording` returns immediately, background task encrypts, reader can transcribe +- Sweep test: on startup, `encryption_pending=true` rows get encrypted diff --git a/docs/superpowers/specs/2026-07-08-low-priority-improvements-design.md b/docs/superpowers/specs/2026-07-08-low-priority-improvements-design.md new file mode 100644 index 00000000..7298ac69 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-low-priority-improvements-design.md @@ -0,0 +1,42 @@ +# Low-Priority Improvements — Design Spec + +**Date:** 2026-07-08 +**Status:** Approved +**Scope:** 2 independent improvements + +--- + +## Item #9: Conditional Chip Poll + +### Problem +The 30s poll calls `refreshChips()` unconditionally — even when sync is off and the machine isn't paired. Re-reads the local DB every 30s for no reason. + +### Approach +Gate the `setInterval` behind the `sync_condition_chips` setting. Only poll when sync is enabled. Use an `$effect` that watches the setting and starts/stops the interval accordingly. When sync is off (the default), no poll runs at all. + +### Details +- Replace the unconditional `setInterval` in `onMount` with an `$effect` that depends on `settings.state.sync_condition_chips` +- When the setting is true: start the 30s interval +- When the setting is false: clear the interval +- The initial `listConditionChips()` call in `onMount` stays unconditional (chips need to load regardless of sync) + +--- + +## Item #10: Keyboard Shortcuts + +### Problem +No keyboard shortcuts for common actions. Clinicians working fast would benefit from push-to-talk and quick-generate. + +### Approach +Add a global keyboard handler on the Record tab: +- **Space** — toggle record/stop (push-to-talk pattern). Only fires when not typing in an input/textarea. +- **Cmd+Enter** — generate SOAP for the selected recording. + +Guard against firing when the user is typing in an input, textarea, or contenteditable element (check `e.target.tagName`). + +### Discoverability +- Add a small hint near the record button: "⌨ Space" in muted text +- Add `title` attributes to buttons mentioning the shortcut + +### Files +- `src/lib/pages/RecordTab.svelte` — add keydown listener + hints diff --git a/docs/superpowers/specs/2026-07-08-medium-priority-improvements-design.md b/docs/superpowers/specs/2026-07-08-medium-priority-improvements-design.md new file mode 100644 index 00000000..a824fc80 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-medium-priority-improvements-design.md @@ -0,0 +1,90 @@ +# Medium-Priority Improvements — Design Spec + +**Date:** 2026-07-08 +**Status:** Approved (all design sections) +**Scope:** 4 independent improvements + +--- + +## Item #5: Reduce Command Boilerplate + +### Problem +36 occurrences of `.map_err(|e| AppError::Other(format!("Task join error: {e}")))` across command files. Each `spawn_blocking(...).await` requires manual JoinError→AppError wrapping. + +### Approach +1. Add `TaskJoin(String)` variant to `AppError` in `crates/core/src/error.rs` +2. Implement `From for AppError` +3. Replace all `.map_err(|e| AppError::Other(format!("Task join error: {e}")))?` with just `?` — the `From` impl handles conversion automatically +4. The 2 transcription cases that extract the error string for `mark_recording_failed` keep manual handling (they need the string content, not just propagation) + +### Scope +- `crates/core/src/error.rs` — add variant + From impl +- ~10 files in `src-tauri/src/commands/` — mechanical find-replace of the map_err pattern +- No behavior change — same error message text + +--- + +## Item #6: Sync Round-Trip Integration Test + +### Problem +All condition chip merge tests are single-DB unit tests. No test exercises the full sync round-trip across two independent databases. + +### Approach +Create `crates/db/tests/condition_chips_sync.rs` that: +1. Creates two in-memory databases (`Database::open_in_memory()`) — "machine A" and "machine B" +2. Adds chips independently on both (simulating offline use) +3. Simulates the sync round-trip: A → server → B (merge_incoming on B), then B → server → A (merge_incoming on A) +4. Asserts both machines converge to identical active chip sets + ordering +5. Tests tombstone propagation across the round-trip + +### Test scenarios +- Both add unique chips → converge to union +- Both add same chip → converge to one copy +- A removes (tombstone), B still has → converges to removed +- A reorders, B has old order → converges to A's order + +--- + +## Item #7: Dependabot Triage + +### Problem +6 open PRs, none ecosystem-blocked per AGENTS.md. Need individual triage. + +### Triage plan + +**Close + add ignore rules (high-risk, not worth migration now):** +- `rand 0.8→0.10` — 4 crates including security, major API change +- `zip 2→4` — two majors, sharing-critical packaging +- `rubato 0.16→3.0` — three majors, STT resampler + +**Try-merge (single-crate, testable):** +- `sha2 0.10→0.11` — security crate, run build+tests, merge if green +- `rodio 0.19→0.22` — audio playback, run build, merge if green + +**Merge after verification:** +- npm patch group (#52) — verify tiptap-markdown minor doesn't break editor, merge if green + +### Dependabot config update +Add ignore rules for rand, zip, rubato major bumps in `.github/dependabot.yml` + +--- + +## Item #8: Split Sharing.svelte + +### Problem +107 lines mixing mode selector, sync toggle, and sub-component routing. + +### Approach +Extract two new section components into `src/lib/components/settings/sharing/`: +- `SharingModes.svelte` — the off/server/client radio block + `refresh()` bootstrap + `sharingOn`/`pairedTo` state. Exposes `mode`, `sharingOn`, and `pairedTo` via callback props so the parent can route sub-components. +- `ConditionChipSync.svelte` — the `sync_condition_chips` toggle. Receives `visible` as a prop (true when sharingOn || pairedTo). + +`Sharing.svelte` shrinks to ~25 lines: holds the `mode`/`sharingOn`/`pairedTo` state (lifted from the modes component via callbacks), imports both new sections + existing ServerWizard/ServerStatus/ClientPair, routes sub-components based on mode. + +### Cross-cutting +Update AGENTS.md "Known deferred debt" to: +- Remove the stale General.svelte note (already split, 33 lines now) +- This is a no-op for Sharing (it's small enough to not warrant a debt note) + +### Pattern +Follows the General.svelte split pattern exactly — parent is a thin orchestrator, sections are siblings in a subdirectory. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6d3d6509..158e3029 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -55,6 +55,7 @@ hex = { workspace = true } reqwest = { workspace = true } keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } axum = "0.8" +async-stream = "0.3" urlencoding = { workspace = true } [dev-dependencies] diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index a0e643be..b6217894 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -253,34 +253,6 @@ pub async fn stop_recording(state: tauri::State<'_, AppState>) -> AppResult 0 { - let enc_path = current.wav_path.clone(); - let enc_result = tokio::task::spawn_blocking(move || { - medical_security::file_crypto::encrypt_file_in_place(&enc_path) - }) - .await - .map_err(|e| AppError::Other(format!("encryption task panicked: {e}")))?; - - match enc_result { - Ok(()) => { - tracing::debug!(path = %current.wav_path.display(), "Recording encrypted at rest"); - } - Err(medical_security::file_crypto::FileCryptoError::Keychain(e)) => { - tracing::warn!(error = %e, "Could not encrypt recording (keychain unavailable); storing plaintext"); - } - Err(e) => { - tracing::warn!(error = %e, path = %current.wav_path.display(), "Could not encrypt recording; storing plaintext"); - } - } - } - let recording_uuid = Uuid::parse_str(¤t.id) .map_err(|e| AppError::Other(format!("invalid recording id: {e}")))?; @@ -302,6 +274,47 @@ pub async fn stop_recording(state: tauri::State<'_, AppState>) -> AppResult 0 { + conn.execute( + "UPDATE recordings SET encryption_pending = 1 WHERE id = ?1", + [&recording_uuid.to_string()], + ) + .map_err(medical_db::DbError::from)?; + } + + // Spawn background encryption — don't block stop_recording. + // The reader handles both plaintext and encrypted files (checks FE1 magic), + // so transcription works regardless. The atomic rename guarantees the reader + // never sees a half-encrypted file. + if file_size > 0 { + let enc_path = current.wav_path.clone(); + let rec_id = recording_uuid; + let db_for_enc = Arc::clone(&state.db); + tokio::task::spawn_blocking(move || { + match medical_security::file_crypto::encrypt_file_in_place(&enc_path) { + Ok(()) => { + tracing::debug!(path = %enc_path.display(), "Recording encrypted at rest (background)"); + if let Ok(conn) = db_for_enc.conn() { + let _ = RecordingsRepo::set_encryption_done(&conn, &rec_id); + } + } + Err(e) => { + tracing::warn!(error = %e, path = %enc_path.display(), "Could not encrypt recording; storing plaintext"); + if let Ok(conn) = db_for_enc.conn() { + let _ = RecordingsRepo::set_encryption_done(&conn, &rec_id); + } + } + } + }); + // NOT awaited — fire and forget. + } + info!( recording_id = %current.id, duration_secs = %format!("{:.1}", duration_secs), @@ -497,12 +510,12 @@ pub async fn check_recording_audio_levels( RecordingsRepo::get_by_id(&conn, &uuid).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; let wav_path = recording.audio_path.clone(); let levels = tokio::task::spawn_blocking(move || compute_audio_levels(&wav_path)) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; if levels.is_silent { warn!( diff --git a/src-tauri/src/commands/conditions.rs b/src-tauri/src/commands/conditions.rs index 8721245d..c96eb099 100644 --- a/src-tauri/src/commands/conditions.rs +++ b/src-tauri/src/commands/conditions.rs @@ -18,7 +18,10 @@ //! next `list` (which always pulls + merges when paired). use std::sync::Arc; +use std::time::Duration; +use futures_util::StreamExt; +use tauri::Emitter; use tracing::instrument; use medical_core::error::{AppError, AppResult}; @@ -86,7 +89,7 @@ pub async fn list_condition_chips( .map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))?; + .map_err(crate::commands::join_err)?; } Err(e) => { tracing::warn!(error = %e, "conditions remote list failed, using local"); @@ -102,7 +105,7 @@ pub async fn list_condition_chips( medical_db::condition_chips::ConditionChipsRepo::list_active(&conn).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Add a condition chip. @@ -126,7 +129,7 @@ pub async fn add_condition_chip( .map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; // 2. Best-effort background sync push (non-blocking). The owned // `PairedConnection` is moved into the task and the `ConditionsRemote` @@ -182,7 +185,7 @@ pub async fn remove_condition_chip( .map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; // 2. Best-effort background sync — push ALL chips (including tombstones) // so the server records the deletion. The owned `PairedConnection` is @@ -257,7 +260,7 @@ pub async fn sync_condition_chips_cmd( medical_db::condition_chips::ConditionChipsRepo::list_all(&conn).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; if let Some((conn, bearer)) = paired_conditions_target(&state) && let Some(remote) = crate::conditions_remote::ConditionsRemote::from( @@ -274,7 +277,7 @@ pub async fn sync_condition_chips_cmd( .map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))?; + .map_err(crate::commands::join_err)?; } // Not paired / sync disabled — return the local active list. @@ -284,7 +287,7 @@ pub async fn sync_condition_chips_cmd( medical_db::condition_chips::ConditionChipsRepo::list_active(&conn).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Reorder condition chips. @@ -312,7 +315,7 @@ pub async fn reorder_condition_chips( .map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; // 2. Best-effort background sync — push ALL chips (including tombstones) // so the server sees the new sort_order values. The owned @@ -366,3 +369,75 @@ pub async fn reorder_condition_chips( Ok(local_list) } + +/// Start a long-lived SSE subscription to the office server's condition-chip +/// change notifications. +/// +/// Spawns a background task that connects to `/v1/condition-chips/events` and +/// emits a `condition-chips-changed` Tauri event for each server-pushed +/// "changed" notification. The frontend listens for this event and calls +/// `refreshChips()` for near-realtime sync across machines. The task runs for +/// the lifetime of the app and reconnects with exponential backoff (capped at +/// 30s) when the stream ends or errors. +/// +/// This is a complement to, not a replacement for, the 30s poll — the poll +/// remains as a safety net in case SSE delivery fails silently. +/// +/// Returns `Ok(())` immediately when not paired / sync disabled (no task is +/// spawned). This command is safe to call repeatedly; each call spawns an +/// independent task. In practice the frontend calls it once on mount. +#[tauri::command] +#[instrument(skip(app, state), name = "conditions::subscribe")] +pub async fn subscribe_condition_chips( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> AppResult<()> { + // Gate the same way the other condition commands do: only subscribe when + // paired + sync enabled. When not paired, return quietly (the frontend + // relies on the 30s poll only). + let Some((conn, bearer)) = paired_conditions_target(&state) else { + return Ok(()); + }; + let http_client = state.http_client.clone(); + tokio::spawn(async move { + let mut backoff = Duration::from_secs(5); + loop { + // `conn` and `bearer` are owned by this task; `ConditionsRemote` + // borrows `conn` from within the task scope (cannot borrow from the + // calling frame because `tokio::spawn` requires `'static`). + let remote = match crate::conditions_remote::ConditionsRemote::from( + &conn, + Some(bearer.clone()), + http_client.clone(), + ) { + Some(r) => r, + None => { + tracing::warn!("condition chip SSE subscription target unavailable, retrying"); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(Duration::from_secs(30)); + continue; + } + }; + match remote.subscribe_events().await { + Ok(stream) => { + tracing::info!("condition chip SSE subscription connected"); + backoff = Duration::from_secs(5); + // The stream from `filter_map` is `!Unpin`; pin it on the + // stack so `StreamExt::next` can borrow it mutably. + tokio::pin!(stream); + while let Some(()) = stream.next().await { + let _ = app.emit("condition-chips-changed", ()); + } + tracing::info!("condition chip SSE stream ended, reconnecting"); + } + Err(e) => tracing::warn!( + error = %e, + "condition chip SSE subscription failed, reconnecting" + ), + } + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(Duration::from_secs(30)); + } + }); + Ok(()) +} diff --git a/src-tauri/src/commands/generation/helpers.rs b/src-tauri/src/commands/generation/helpers.rs index fd8b9960..f31861c4 100644 --- a/src-tauri/src/commands/generation/helpers.rs +++ b/src-tauri/src/commands/generation/helpers.rs @@ -72,7 +72,7 @@ pub(super) async fn load_recording_and_settings( Ok::<_, AppError>((recording, settings, config)) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Resolve the AI provider from the registry using the settings provider name. @@ -102,7 +102,7 @@ pub(super) async fn persist_recording( RecordingsRepo::update(&conn, &recording).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Parse a template string into the `SoapTemplate` enum. diff --git a/src-tauri/src/commands/letter_audiences.rs b/src-tauri/src/commands/letter_audiences.rs index 5fca8a5d..b431d78d 100644 --- a/src-tauri/src/commands/letter_audiences.rs +++ b/src-tauri/src/commands/letter_audiences.rs @@ -20,7 +20,7 @@ pub async fn list_letter_audiences( LetterAudiencesRepo::list_all(&conn).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } #[tauri::command] @@ -49,7 +49,7 @@ pub async fn upsert_letter_audience( LetterAudiencesRepo::upsert(&conn, &audience_clone).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; // PHI guardrail: audience names are physician-authored free text that // could embed a recipient name; log the id + name length only. info!( @@ -72,7 +72,7 @@ pub async fn delete_letter_audience(state: tauri::State<'_, AppState>, id: Uuid) }) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; info!(%id, "Deleted letter audience"); Ok(()) } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ea528f79..d3695265 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -27,6 +27,12 @@ use std::path::{Path, PathBuf}; use medical_core::error::{AppError, AppResult}; use medical_db::Database; +/// Convert a tokio JoinError into an AppError. Used by spawn_blocking call +/// sites to avoid repeating the format! boilerplate 36 times. +pub fn join_err(e: tokio::task::JoinError) -> AppError { + AppError::Other(format!("Task join error: {e}")) +} + /// Resolve the recordings directory from settings. /// /// If the user has configured a custom `storage_path`, use it. diff --git a/src-tauri/src/commands/recordings_edit.rs b/src-tauri/src/commands/recordings_edit.rs index 8e0aabe4..7a4e890a 100644 --- a/src-tauri/src/commands/recordings_edit.rs +++ b/src-tauri/src/commands/recordings_edit.rs @@ -72,7 +72,7 @@ pub async fn save_recording_field( ) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Inner logic — testable without `tauri::State`. diff --git a/src-tauri/src/commands/support.rs b/src-tauri/src/commands/support.rs index 8c999f34..58887d33 100644 --- a/src-tauri/src/commands/support.rs +++ b/src-tauri/src/commands/support.rs @@ -87,7 +87,7 @@ pub async fn export_support_bundle(file_path: String) -> AppResult<()> { Ok(()) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } #[cfg(test)] diff --git a/src-tauri/src/commands/training_corpus.rs b/src-tauri/src/commands/training_corpus.rs index 7ae4dfeb..ccdb3122 100644 --- a/src-tauri/src/commands/training_corpus.rs +++ b/src-tauri/src/commands/training_corpus.rs @@ -34,7 +34,7 @@ pub async fn training_corpus_counts(state: tauri::State<'_, AppState>) -> AppRes GenerationsRepo::count_by_status(&conn) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; Ok(CorpusCounts { candidates: c, promoted: p, @@ -56,7 +56,7 @@ pub async fn training_corpus_list( GenerationsRepo::list_by_status(&conn, &status, limit.unwrap_or(50), offset.unwrap_or(0)) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; Ok(GenerationPage { items, total }) } @@ -74,6 +74,6 @@ pub async fn training_corpus_set_status( GenerationsRepo::set_corpus_status(&conn, id, &new_status) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; Ok(()) } diff --git a/src-tauri/src/commands/transcription/inner.rs b/src-tauri/src/commands/transcription/inner.rs index 14463031..dc1d0ddb 100644 --- a/src-tauri/src/commands/transcription/inner.rs +++ b/src-tauri/src/commands/transcription/inner.rs @@ -94,7 +94,7 @@ pub async fn transcribe_recording_inner( Ok::<_, AppError>(recording) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; let wav_path = if recording.audio_path.exists() { recording.audio_path.clone() diff --git a/src-tauri/src/commands/user_dictionary.rs b/src-tauri/src/commands/user_dictionary.rs index eae31273..16bd8f6f 100644 --- a/src-tauri/src/commands/user_dictionary.rs +++ b/src-tauri/src/commands/user_dictionary.rs @@ -39,7 +39,7 @@ pub async fn user_dict_list(state: tauri::State<'_, AppState>) -> AppResult, word: String) -> A medical_db::user_dictionary::UserDictionaryRepo::add(&conn, &word).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } #[tauri::command] @@ -72,5 +72,5 @@ pub async fn user_dict_remove(state: tauri::State<'_, AppState>, word: String) - .map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } diff --git a/src-tauri/src/commands/vocabulary.rs b/src-tauri/src/commands/vocabulary.rs index 8bcce5bd..fbf0243c 100644 --- a/src-tauri/src/commands/vocabulary.rs +++ b/src-tauri/src/commands/vocabulary.rs @@ -50,7 +50,7 @@ pub async fn list_vocabulary_entries( } }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Add a new vocabulary correction entry. @@ -102,7 +102,7 @@ pub async fn add_vocabulary_entry( VocabularyRepo::insert(&conn, &entry_clone).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; // PHI guard: do not log find_text/replacement — vocabulary entries may // contain patient-specific medication/dosage text (AGENTS.md line 6). info!("Vocabulary entry added"); @@ -146,7 +146,7 @@ pub async fn update_vocabulary_entry( VocabularyRepo::get_by_id(&conn, &uuid).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; let entry = VocabularyEntry { id: existing.id, @@ -168,7 +168,7 @@ pub async fn update_vocabulary_entry( VocabularyRepo::update(&conn, &entry_clone).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; Ok(entry) } @@ -190,7 +190,7 @@ pub async fn delete_vocabulary_entry( VocabularyRepo::delete(&conn, &uuid).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Delete all vocabulary entries. @@ -207,7 +207,7 @@ pub async fn delete_all_vocabulary_entries(state: tauri::State<'_, AppState>) -> VocabularyRepo::delete_all(&conn).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Get vocabulary counts as `(total, enabled)`. @@ -224,7 +224,7 @@ pub async fn get_vocabulary_count(state: tauri::State<'_, AppState>) -> AppResul VocabularyRepo::count(&conn).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Import vocabulary entries from a JSON file. @@ -309,7 +309,7 @@ pub async fn import_vocabulary_json( Ok::<_, AppError>(()) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; info!(count, path = %file_path, "Imported vocabulary entries"); Ok(count) @@ -336,7 +336,7 @@ pub async fn export_vocabulary_json( VocabularyRepo::list_all(&conn).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))?? + .map_err(crate::commands::join_err)?? }; let count = entries.len() as u32; @@ -384,7 +384,7 @@ pub async fn test_vocabulary_correction( VocabularyRepo::list_enabled(&conn).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))?? + .map_err(crate::commands::join_err)?? }; Ok(vocabulary_corrector::apply_corrections(&text, &entries)) diff --git a/src-tauri/src/conditions_remote.rs b/src-tauri/src/conditions_remote.rs index 61b07c47..c504992d 100644 --- a/src-tauri/src/conditions_remote.rs +++ b/src-tauri/src/conditions_remote.rs @@ -6,6 +6,9 @@ //! through here instead of the local repo so the server stays the //! canonical source of truth. +use std::time::Duration; + +use futures_util::StreamExt; use medical_core::error::{AppError, AppResult}; use medical_core::types::condition_chip::ConditionChip; use medical_core::types::endpoint::http_url; @@ -97,6 +100,56 @@ impl<'a> ConditionsRemote<'a> { .await .map_err(|e| AppError::Other(format!("conditions sync parse: {e}"))) } + + /// Subscribe to SSE change notifications from the office server. + /// + /// Returns a stream that yields `()` for each `data: changed` event pushed + /// by the server's `/v1/condition-chips/events` endpoint. The stream stays + /// open until the connection drops or the server closes it; callers should + /// wrap it in a reconnect loop with backoff. The request uses a long + /// timeout (300s) because SSE is a long-lived connection — reqwest will + /// keep the response body streaming, and each server push resets the idle + /// window. + /// + /// The `data: connected` initial event is filtered out (only `changed` + /// events surface to the caller). + pub async fn subscribe_events(&self) -> AppResult> { + let url = format!( + "{}/v1/condition-chips/events", + self.base_url().ok_or_else(|| { + AppError::Other("no vocab base URL for conditions remote".into()) + })? + ); + let resp = self + .client + .get(&url) + .timeout(Duration::from_secs(300)) + .bearer_auth(&self.bearer) + .send() + .await + .map_err(|e| AppError::Other(format!("SSE connect: {e}")))?; + if !resp.status().is_success() { + return Err(AppError::Other(format!( + "SSE connect failed: {}", + resp.status() + ))); + } + let stream = resp.bytes_stream().filter_map(|chunk| async move { + match chunk { + Ok(bytes) => { + let text = String::from_utf8_lossy(&bytes); + for line in text.lines() { + if line.starts_with("data: changed") { + return Some(()); + } + } + None + } + Err(_) => None, + } + }); + Ok(stream) + } } async fn check_status(resp: &reqwest::Response) -> AppResult<()> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 712e38a7..b2cd4dfe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -367,6 +367,7 @@ pub fn run() { commands::conditions::remove_condition_chip, commands::conditions::sync_condition_chips_cmd, commands::conditions::reorder_condition_chips, + commands::conditions::subscribe_condition_chips, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/sharing_vocab_api.rs b/src-tauri/src/sharing_vocab_api.rs index 939db941..b2f4b375 100644 --- a/src-tauri/src/sharing_vocab_api.rs +++ b/src-tauri/src/sharing_vocab_api.rs @@ -42,14 +42,17 @@ use axum::{ Json, Router, extract::{Path, Query, State as AxumState}, http::{HeaderMap, StatusCode}, + response::sse::{Event, Sse}, routing::{get, post, put}, }; use chrono::Utc; +use futures_util::Stream; use medical_core::types::settings::ContextTemplate; use medical_core::types::vocabulary::{VocabularyCategory, VocabularyEntry}; use medical_db::{Database, settings::SettingsRepo, vocabulary::VocabularyRepo}; use medical_sharing::token_store::TokenStore; use serde::Deserialize; +use tokio::sync::broadcast; use tokio::task::JoinHandle; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -61,6 +64,9 @@ use uuid::Uuid; struct ApiState { db: Arc, tokens: Arc, + /// Broadcasts `()` whenever condition chips change on the server so SSE + /// subscribers can push realtime notifications to their clients. + chips_changed_tx: broadcast::Sender<()>, } /// Spawn the vocab/templates/dictionary HTTP API server on `0.0.0.0:{port}`. @@ -73,7 +79,12 @@ pub async fn spawn( tokens: Arc, port: u16, ) -> Result, medical_core::error::AppError> { - let state = ApiState { db, tokens }; + let (chips_changed_tx, _) = broadcast::channel::<()>(16); + let state = ApiState { + db, + tokens, + chips_changed_tx, + }; let app = Router::new() .route( "/v1/vocabulary", @@ -112,6 +123,10 @@ pub async fn spawn( "/v1/condition-chips/sync", post(condition_chips_sync_handler), ) + .route( + "/v1/condition-chips/events", + get(condition_chips_events_handler), + ) .with_state(state); let addr: std::net::SocketAddr = format!("0.0.0.0:{port}") @@ -677,6 +692,11 @@ async fn condition_chips_sync_handler( StatusCode::INTERNAL_SERVER_ERROR })?; + // Notify SSE subscribers that chips changed. Best-effort: no receivers is + // not an error (send returns Err only when there are no active receivers, + // which is the normal idle case). + let _ = state.chips_changed_tx.send(()); + info!( incoming_count, result_count = merged.len(), @@ -684,3 +704,29 @@ async fn condition_chips_sync_handler( ); Ok(Json(merged)) } + +/// GET /v1/condition-chips/events — Server-Sent Events stream. +/// +/// Pushes a `data: connected` event immediately on connection, then a +/// `data: changed` event each time a condition-chips sync completes on the +/// server. Clients use this to refresh their local chip list in near-realtime +/// instead of waiting for the 30s poll. The stream stays open until the client +/// disconnects or the server shuts down. +async fn condition_chips_events_handler( + AxumState(state): AxumState, + headers: HeaderMap, +) -> Result>>, StatusCode> { + let _ = authorize(&state, &headers)?; + let mut rx = state.chips_changed_tx.subscribe(); + let stream = async_stream::stream! { + yield Ok(Event::default().data("connected")); + loop { + match rx.recv().await { + Ok(()) => yield Ok(Event::default().data("changed")), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + } + } + }; + Ok(Sse::new(stream)) +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 3391101d..508c8529 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -659,6 +659,55 @@ impl AppState { } } + // Sweep: encrypt any recordings left pending by a crash. A row is + // flagged encryption_pending=1 by stop_recording right before it + // spawns the background encrypt task; the task clears the flag when + // done. If the app died in between, the WAV is still plaintext at + // rest — finish the encryption here so no PHI audio is left + // unencrypted. Best-effort: a failure here must not block boot. + if let Ok(conn) = db.conn() { + let pending = match RecordingsRepo::list_encryption_pending(&conn) { + Ok(p) => p, + Err(e) => { + tracing::warn!(error = %e, "encryption sweep: list_encryption_pending failed"); + Vec::new() + } + }; + if !pending.is_empty() { + info!( + count = pending.len(), + "Encrypting pending recordings from previous session" + ); + for (id, path) in &pending { + // Guard against the crash-after-encrypt-but-before-clear- + // flag window: if the file is already encrypted on disk + // (FE1 magic), just clear the flag instead of re-encrypting + // — re-encrypting ciphertext would corrupt the file. + if medical_security::file_crypto::is_encrypted(path) { + let _ = RecordingsRepo::set_encryption_done(&conn, id); + tracing::debug!( + recording_id = %id, + "Pending recording already encrypted on disk; cleared flag" + ); + continue; + } + match medical_security::file_crypto::encrypt_file_in_place(path) { + Ok(()) => { + let _ = RecordingsRepo::set_encryption_done(&conn, id); + tracing::debug!(recording_id = %id, "Encrypted pending recording"); + } + Err(e) => { + tracing::warn!( + error = %e, + recording_id = %id, + "Failed to encrypt pending recording" + ); + } + } + } + } + } + let config_dir = data_dir.join("config"); let keys = KeyStorage::open(&config_dir)?; diff --git a/src/lib/components/ConditionChips.svelte b/src/lib/components/ConditionChips.svelte index c263ac0c..a9af2511 100644 --- a/src/lib/components/ConditionChips.svelte +++ b/src/lib/components/ConditionChips.svelte @@ -1,5 +1,7 @@