From 63bfc293cb6c1f43f9f3152f7bcad09ec3fbaa6e Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:30:04 -0500 Subject: [PATCH] feat(briefs): create/list/get_feature_brief + set_brief_exposed command bodies (T10.6, M1.4/M1.6) --- src-tauri/src/commands.rs | 230 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 219 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0b6cede..a792abb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3100,33 +3100,173 @@ pub async fn sync_status( Ok(rows.iter().map(job_to_info).collect()) } -// ---- Feature briefs + MCP server (Phase 10b, ADR-0011) ---- +// ---- 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/.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( - _meeting_id: MeetingId, - _target_repo: Option, + state: State<'_, AppState>, + meeting_id: MeetingId, + target_repo: Option, ) -> WaResult { - // T10.6: distill transcript → structured brief via the configured LlmProvider. - Err(not_implemented("create_feature_brief")) + 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, + meeting_id: &MeetingId, + target_repo: Option<&str>, + settings: &Settings, +) -> WaResult { + 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( - _meeting_id: Option, + state: State<'_, AppState>, + meeting_id: Option, ) -> WaResult> { - Err(not_implemented("list_feature_briefs")) + 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/.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(_id: String) -> WaResult { - Err(not_implemented("get_feature_brief")) +pub async fn get_feature_brief(state: State<'_, AppState>, id: String) -> WaResult { + 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(_id: String, _exposed: bool) -> WaResult<()> { - Err(not_implemented("set_brief_exposed")) +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] @@ -3517,4 +3657,72 @@ mod tests { 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 = 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 = 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()); + } }