feat(briefs): storage layer for feature_briefs (T10.6, M1.1)

This commit is contained in:
iamdoubz
2026-07-07 00:03:21 -05:00
parent 3038b9d05d
commit d14a6766e5
+139 -2
View File
@@ -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<u32>,
}
/// 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<String>,
pub path: String, // briefs/<id>.json, relative to the meeting's folder
pub exposed: bool,
pub created_at: i64,
}
impl From<FeatureBriefRow> 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/<id>.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<String>,
pub target_repo: Option<String>,
pub context_excerpts: Vec<ContextExcerpt>,
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<Vec<SyncJobRow>, 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<Vec<FeatureBriefInfo>, 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<FeatureBriefRow, StoreError>;
/// 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<Vec<FeatureBriefInfo>, 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<FeatureBriefRow, StoreError> {
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