1use super::*;
2
3impl Store {
4 pub fn upsert_guidance_candidate(&self, c: &crate::guidance::GuidanceCandidate) -> Result<()> {
5 let action_json = serde_json::to_string(&c.action)?;
6 let evidence_json = serde_json::to_string(&c.evidence)?;
7 self.conn.execute(
8 "INSERT OR REPLACE INTO guidance_candidates
9 (id, artifact_kind, artifact_id, action_json, status, rationale,
10 evidence_json, created_at_ms, applied_at_ms, treatment_fingerprint,
11 experiment_id, backup_path)
12 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
13 params![
14 c.id,
15 c.artifact.kind.as_str(),
16 c.artifact.slug,
17 action_json,
18 c.status.as_str(),
19 c.rationale,
20 evidence_json,
21 c.created_at_ms as i64,
22 c.applied_at_ms.map(|v| v as i64),
23 c.treatment_fingerprint.as_deref(),
24 c.experiment_id.as_deref(),
25 c.backup_path.as_deref(),
26 ],
27 )?;
28 Ok(())
29 }
30
31 pub fn list_guidance_candidates(&self) -> Result<Vec<crate::guidance::GuidanceCandidate>> {
32 let mut stmt = self.conn.prepare(GUIDANCE_CANDIDATE_SELECT)?;
33 let rows = stmt.query_map([], guidance_candidate_row)?;
34 rows.map(|r| r.map_err(anyhow::Error::from)).collect()
35 }
36
37 pub fn get_guidance_candidate(
38 &self,
39 id: &str,
40 ) -> Result<Option<crate::guidance::GuidanceCandidate>> {
41 self.conn
42 .query_row(
43 &format!("{GUIDANCE_CANDIDATE_SELECT} WHERE id = ?1"),
44 params![id],
45 guidance_candidate_row,
46 )
47 .optional()
48 .map_err(Into::into)
49 }
50
51 pub fn rejected_guidance_candidates(
52 &self,
53 artifact: &crate::guidance::ArtifactRef,
54 limit: usize,
55 ) -> Result<Vec<crate::guidance::GuidanceCandidate>> {
56 let sql = format!(
57 "{GUIDANCE_CANDIDATE_SELECT} WHERE artifact_kind = ?1 AND artifact_id = ?2
58 AND status = 'rejected' ORDER BY created_at_ms DESC LIMIT ?3"
59 );
60 let mut stmt = self.conn.prepare(&sql)?;
61 let rows = stmt.query_map(
62 params![artifact.kind.as_str(), artifact.slug.as_str(), limit as i64],
63 guidance_candidate_row,
64 )?;
65 rows.map(|r| r.map_err(anyhow::Error::from)).collect()
66 }
67
68 pub fn set_guidance_candidate_status(
69 &self,
70 id: &str,
71 status: crate::guidance::CandidateStatus,
72 ) -> Result<()> {
73 self.conn.execute(
74 "UPDATE guidance_candidates SET status = ?2 WHERE id = ?1",
75 params![id, status.as_str()],
76 )?;
77 Ok(())
78 }
79
80 pub fn mark_guidance_candidate_applied(
81 &self,
82 id: &str,
83 applied_at_ms: u64,
84 treatment_fingerprint: &str,
85 backup_path: &str,
86 experiment_id: Option<&str>,
87 ) -> Result<()> {
88 self.conn.execute(
89 "UPDATE guidance_candidates
90 SET status = 'applied', applied_at_ms = ?2, treatment_fingerprint = ?3,
91 backup_path = ?4, experiment_id = ?5
92 WHERE id = ?1",
93 params![
94 id,
95 applied_at_ms as i64,
96 treatment_fingerprint,
97 backup_path,
98 experiment_id
99 ],
100 )?;
101 Ok(())
102 }
103}
104
105const GUIDANCE_CANDIDATE_SELECT: &str = "SELECT id, artifact_kind, artifact_id, action_json,
106 status, rationale, evidence_json, created_at_ms, applied_at_ms,
107 treatment_fingerprint, experiment_id, backup_path FROM guidance_candidates";
108
109fn guidance_candidate_row(
110 r: &rusqlite::Row<'_>,
111) -> rusqlite::Result<crate::guidance::GuidanceCandidate> {
112 let kind: String = r.get(1)?;
113 let status: String = r.get(4)?;
114 Ok(crate::guidance::GuidanceCandidate {
115 id: r.get(0)?,
116 artifact: crate::guidance::ArtifactRef {
117 kind: crate::guidance::ArtifactKind::parse(&kind)
118 .unwrap_or(crate::guidance::ArtifactKind::Skill),
119 slug: r.get(2)?,
120 },
121 action: serde_json::from_str(&r.get::<_, String>(3)?)
122 .unwrap_or(crate::guidance::CandidateAction::ReviewOnly),
123 status: crate::guidance::CandidateStatus::parse(&status)
124 .unwrap_or(crate::guidance::CandidateStatus::Proposed),
125 rationale: r.get(5)?,
126 evidence: serde_json::from_str(&r.get::<_, String>(6)?).unwrap_or_default(),
127 created_at_ms: r.get::<_, i64>(7)? as u64,
128 applied_at_ms: r.get::<_, Option<i64>>(8)?.map(|v| v as u64),
129 treatment_fingerprint: r.get(9)?,
130 experiment_id: r.get(10)?,
131 backup_path: r.get(11)?,
132 })
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use crate::guidance::{ArtifactKind, ArtifactRef, CandidateAction, CandidateStatus};
139
140 #[test]
141 fn candidate_round_trip_and_status() -> anyhow::Result<()> {
142 let dir = tempfile::tempdir()?;
143 let store = Store::open(&dir.path().join("k.db"))?;
144 let c = candidate();
145 store.upsert_guidance_candidate(&c)?;
146 store.set_guidance_candidate_status("c1", CandidateStatus::Rejected)?;
147 let got = store.get_guidance_candidate("c1")?.unwrap();
148 assert_eq!(got.status, CandidateStatus::Rejected);
149 Ok(())
150 }
151
152 #[test]
153 fn rejected_candidates_filter_by_artifact() -> anyhow::Result<()> {
154 let dir = tempfile::tempdir()?;
155 let store = Store::open(&dir.path().join("k.db"))?;
156 store.upsert_guidance_candidate(&candidate())?;
157 store.set_guidance_candidate_status("c1", CandidateStatus::Rejected)?;
158 let got = store.rejected_guidance_candidates(&candidate().artifact, 10)?;
159 assert_eq!(got.len(), 1);
160 Ok(())
161 }
162
163 fn candidate() -> crate::guidance::GuidanceCandidate {
164 crate::guidance::GuidanceCandidate {
165 id: "c1".into(),
166 artifact: ArtifactRef {
167 kind: ArtifactKind::Skill,
168 slug: "tdd".into(),
169 },
170 action: CandidateAction::ReviewOnly,
171 status: CandidateStatus::Proposed,
172 rationale: "inspect".into(),
173 evidence: vec!["e".into()],
174 created_at_ms: 1,
175 applied_at_ms: None,
176 treatment_fingerprint: None,
177 experiment_id: None,
178 backup_path: None,
179 }
180 }
181}