feat(briefs): create/list/get_feature_brief + set_brief_exposed command bodies (T10.6, M1.4/M1.6)
This commit is contained in:
+219
-11
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user