Feature chore bug 005 #19
+219
-11
@@ -3100,33 +3100,173 @@ pub async fn sync_status(
|
|||||||
Ok(rows.iter().map(job_to_info).collect())
|
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]
|
#[tauri::command]
|
||||||
pub async fn create_feature_brief(
|
pub async fn create_feature_brief(
|
||||||
_meeting_id: MeetingId,
|
state: State<'_, AppState>,
|
||||||
_target_repo: Option<String>,
|
meeting_id: MeetingId,
|
||||||
|
target_repo: Option<String>,
|
||||||
) -> WaResult<FeatureBrief> {
|
) -> WaResult<FeatureBrief> {
|
||||||
// T10.6: distill transcript → structured brief via the configured LlmProvider.
|
let guard = state.session.lock().await;
|
||||||
Err(not_implemented("create_feature_brief"))
|
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]
|
#[tauri::command]
|
||||||
pub async fn list_feature_briefs(
|
pub async fn list_feature_briefs(
|
||||||
_meeting_id: Option<MeetingId>,
|
state: State<'_, AppState>,
|
||||||
|
meeting_id: Option<MeetingId>,
|
||||||
) -> WaResult<Vec<FeatureBriefInfo>> {
|
) -> 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]
|
#[tauri::command]
|
||||||
pub async fn get_feature_brief(_id: String) -> WaResult<FeatureBrief> {
|
pub async fn get_feature_brief(state: State<'_, AppState>, id: String) -> WaResult<FeatureBrief> {
|
||||||
Err(not_implemented("get_feature_brief"))
|
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).
|
/// Scope control: include/exclude a brief from the MCP server (FR-MCP-3).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_brief_exposed(_id: String, _exposed: bool) -> WaResult<()> {
|
pub async fn set_brief_exposed(
|
||||||
Err(not_implemented("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]
|
#[tauri::command]
|
||||||
@@ -3517,4 +3657,72 @@ mod tests {
|
|||||||
let b = list_item("22222222-bbbb", "Untitled meeting");
|
let b = list_item("22222222-bbbb", "Untitled meeting");
|
||||||
assert_ne!(bulk_export_stem(&a), bulk_export_stem(&b));
|
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