feat(briefs): storage layer for feature_briefs (T10.6, M1.1)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user