feat(briefs): create/list/get_feature_brief + set_brief_exposed command bodies (T10.6, M1.4/M1.6)

This commit is contained in:
iamdoubz
2026-07-07 00:30:04 -05:00
parent 4fc3df504a
commit 63bfc293cb
+219 -11
View File
@@ -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/<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(
_meeting_id: MeetingId,
_target_repo: Option<String>,
state: State<'_, AppState>,
meeting_id: MeetingId,
target_repo: Option<String>,
) -> WaResult<FeatureBrief> {
// 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<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(
_meeting_id: Option<MeetingId>,
state: State<'_, AppState>,
meeting_id: Option<MeetingId>,
) -> WaResult<Vec<FeatureBriefInfo>> {
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/<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(_id: String) -> WaResult<FeatureBrief> {
Err(not_implemented("get_feature_brief"))
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(_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<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());
}
}