From d14a6766e59cc8e9a07340bb56d103e34a00011f Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:03:21 -0500 Subject: [PATCH] feat(briefs): storage layer for feature_briefs (T10.6, M1.1) --- src-tauri/src/storage/mod.rs | 141 ++++++++++++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index eb10ddc..609b4da 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -5,8 +5,8 @@ //! summary) are regenerable; retention never touches an in-progress meeting. use crate::models::{ - ActionItem, CalendarEvent, ImportedEvent, MeetingId, MeetingListItem, MeetingStatus, - Participant, SearchHit, SpeakerInfo, TranscriptSegment, + ActionItem, CalendarEvent, ContextExcerpt, FeatureBriefInfo, ImportedEvent, MeetingId, + MeetingListItem, MeetingStatus, Participant, SearchHit, SpeakerInfo, TranscriptSegment, }; use crate::paths; use async_trait::async_trait; @@ -128,6 +128,64 @@ pub struct Retention { pub max_size_gb: Option, } +/// A feature brief distilled from a meeting (ADR-0011, M1). Holds only a +/// `credential_ref`-style pointer (`path`) into the meeting's `briefs/` +/// folder — the JSON body is the source of truth; this row is the index +/// `list_feature_briefs`/the MCP scope check reads. Maps 1:1 to the +/// `feature_briefs` table. +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct FeatureBriefRow { + pub id: String, + pub meeting_id: MeetingId, + pub title: String, + pub target_repo: Option, + pub path: String, // briefs/.json, relative to the meeting's folder + pub exposed: bool, + pub created_at: i64, +} + +impl From for FeatureBriefInfo { + fn from(row: FeatureBriefRow) -> Self { + FeatureBriefInfo { + id: row.id, + meeting_id: row.meeting_id, + title: row.title, + target_repo: row.target_repo, + exposed: row.exposed, + } + } +} + +/// On-disk shape of `briefs/.json` (`docs/03-data-model.md`, ADR-0011) — +/// the schema/provenance envelope around the same fields as the IPC +/// `FeatureBrief` (`models.rs`). Written by `create_feature_brief` (M1.4), +/// sealed at rest with the vault when unlocked, exactly like `summary.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BriefFile { + pub schema: u32, + pub id: String, + pub meeting_id: MeetingId, + pub generated_at: i64, + pub provider: String, + pub model: String, + pub title: String, + pub problem: String, + pub desired_outcome: String, + pub acceptance_criteria: Vec, + pub target_repo: Option, + pub context_excerpts: Vec, + pub source: BriefSource, +} + +/// `schema`-envelope companion recording which meeting this brief came from, +/// by name and time — distinct from the FK `meeting_id`, which can outlive a +/// renamed/retitled meeting. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BriefSource { + pub meeting_title: String, + pub at: i64, +} + /// A configured upload destination as stored in the DB (ADR-0010). Holds only a /// `credential_ref` into the OS credential store — never the secret itself /// (FR-SYNC-6). Maps 1:1 to the `sync_targets` table. @@ -297,6 +355,24 @@ pub trait Store: Send + Sync { &self, meeting_id: Option<&MeetingId>, ) -> Result, StoreError>; + + // ---- Feature briefs (Phase 10 M1, ADR-0011) ---- + /// Indexes a brief already sealed to disk by `create_feature_brief` + /// (M1.4). Deletion cascades via the meeting FK — `delete_meeting` + /// already drops the row (and its folder) with the rest of the meeting. + async fn insert_feature_brief(&self, row: FeatureBriefRow) -> Result<(), StoreError>; + /// Newest first; `None` lists across all meetings (the MCP "recent + /// briefs" surface and the frontend's per-meeting list share this call). + async fn list_feature_briefs( + &self, + meeting_id: Option<&MeetingId>, + ) -> Result, StoreError>; + /// The full row (incl. `path`) so a caller can resolve and read the + /// sealed JSON file itself (`get_feature_brief`, MCP `get_feature_brief` + /// tool). + async fn get_feature_brief_row(&self, id: &str) -> Result; + /// Scope control: include/exclude a brief from the MCP server (FR-MCP-3). + async fn set_brief_exposed(&self, id: &str, exposed: bool) -> Result<(), StoreError>; } /// SQLite-backed store. Migrations live in `migrations/` (`sqlx::migrate!`). @@ -1484,6 +1560,67 @@ impl Store for SqliteStore { }; Ok(rows) } + + async fn insert_feature_brief(&self, row: FeatureBriefRow) -> Result<(), StoreError> { + sqlx::query( + "INSERT INTO feature_briefs (id, meeting_id, title, target_repo, path, exposed, created_at) \ + VALUES (?,?,?,?,?,?,?)", + ) + .bind(&row.id) + .bind(&row.meeting_id) + .bind(&row.title) + .bind(&row.target_repo) + .bind(&row.path) + .bind(row.exposed) + .bind(row.created_at) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn list_feature_briefs( + &self, + meeting_id: Option<&MeetingId>, + ) -> Result, StoreError> { + let rows = match meeting_id { + Some(m) => { + sqlx::query_as::<_, FeatureBriefRow>( + "SELECT * FROM feature_briefs WHERE meeting_id = ? ORDER BY created_at DESC", + ) + .bind(m) + .fetch_all(&self.pool) + .await? + } + None => { + sqlx::query_as::<_, FeatureBriefRow>( + "SELECT * FROM feature_briefs ORDER BY created_at DESC", + ) + .fetch_all(&self.pool) + .await? + } + }; + Ok(rows.into_iter().map(FeatureBriefInfo::from).collect()) + } + + async fn get_feature_brief_row(&self, id: &str) -> Result { + sqlx::query_as::<_, FeatureBriefRow>("SELECT * FROM feature_briefs WHERE id = ?") + .bind(id) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| StoreError::NotFound(format!("feature brief {id}"))) + } + + async fn set_brief_exposed(&self, id: &str, exposed: bool) -> Result<(), StoreError> { + let res = sqlx::query("UPDATE feature_briefs SET exposed = ? WHERE id = ?") + .bind(exposed) + .bind(id) + .execute(&self.pool) + .await?; + if res.rows_affected() == 0 { + return Err(StoreError::NotFound(format!("feature brief {id}"))); + } + Ok(()) + } } /// Read a derived artifact, transparently decrypting it if the vault sealed it