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.
|
//! summary) are regenerable; retention never touches an in-progress meeting.
|
||||||
|
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
ActionItem, CalendarEvent, ImportedEvent, MeetingId, MeetingListItem, MeetingStatus,
|
ActionItem, CalendarEvent, ContextExcerpt, FeatureBriefInfo, ImportedEvent, MeetingId,
|
||||||
Participant, SearchHit, SpeakerInfo, TranscriptSegment,
|
MeetingListItem, MeetingStatus, Participant, SearchHit, SpeakerInfo, TranscriptSegment,
|
||||||
};
|
};
|
||||||
use crate::paths;
|
use crate::paths;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -128,6 +128,64 @@ pub struct Retention {
|
|||||||
pub max_size_gb: Option<u32>,
|
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
|
/// 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
|
/// `credential_ref` into the OS credential store — never the secret itself
|
||||||
/// (FR-SYNC-6). Maps 1:1 to the `sync_targets` table.
|
/// (FR-SYNC-6). Maps 1:1 to the `sync_targets` table.
|
||||||
@@ -297,6 +355,24 @@ pub trait Store: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
meeting_id: Option<&MeetingId>,
|
meeting_id: Option<&MeetingId>,
|
||||||
) -> Result<Vec<SyncJobRow>, StoreError>;
|
) -> 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!`).
|
/// SQLite-backed store. Migrations live in `migrations/` (`sqlx::migrate!`).
|
||||||
@@ -1484,6 +1560,67 @@ impl Store for SqliteStore {
|
|||||||
};
|
};
|
||||||
Ok(rows)
|
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
|
/// Read a derived artifact, transparently decrypting it if the vault sealed it
|
||||||
|
|||||||
Reference in New Issue
Block a user