1use rusqlite::params;
19use serde::Serialize;
20
21use super::error::StoreError;
22use super::store::Store;
23
24#[derive(Debug, Clone, Serialize)]
26pub struct ProjectInstructionObservation {
27 pub path: String,
28 pub content_hash: String,
29 pub size_bytes: i64,
30 pub owner: String,
32 pub existed: bool,
33 pub observed_at: i64,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
38#[serde(rename_all = "snake_case")]
39#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "ProjectInstructionChange")]
40pub enum InstructionChange {
41 FirstSeen,
43 Unchanged,
44 Modified,
46 Added,
48 Removed,
50}
51
52impl Store {
53 pub fn project_instructions_record(
61 &self,
62 agent_id: &str,
63 observations: &[ProjectInstructionObservation],
64 ) -> Result<(), StoreError> {
65 let mut conn = self.conn.lock().unwrap();
66 let tx = conn.transaction()?;
67 for obs in observations {
68 tx.execute(
69 "INSERT INTO db_agent_project_instructions
70 (agent_id, path, content_hash, size_bytes, owner, existed, observed_at)
71 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
72 ON CONFLICT(agent_id, path) DO UPDATE SET
73 content_hash = excluded.content_hash,
74 size_bytes = excluded.size_bytes,
75 owner = excluded.owner,
76 existed = excluded.existed,
77 observed_at = excluded.observed_at",
78 params![
79 agent_id,
80 obs.path,
81 obs.content_hash,
82 obs.size_bytes,
83 obs.owner,
84 if obs.existed { 1 } else { 0 },
85 obs.observed_at,
86 ],
87 )?;
88 }
89 tx.commit()?;
90 Ok(())
91 }
92
93 pub fn project_instructions_list(
95 &self,
96 agent_id: &str,
97 ) -> Result<Vec<ProjectInstructionObservation>, StoreError> {
98 let conn = self.conn.lock().unwrap();
99 let mut stmt = conn.prepare(
100 "SELECT path, content_hash, size_bytes, owner, existed, observed_at
101 FROM db_agent_project_instructions
102 WHERE agent_id = ?1
103 ORDER BY path",
104 )?;
105 let rows = stmt
106 .query_map(params![agent_id], |row| {
107 Ok(ProjectInstructionObservation {
108 path: row.get(0)?,
109 content_hash: row.get(1)?,
110 size_bytes: row.get(2)?,
111 owner: row.get(3)?,
112 existed: row.get::<_, i64>(4)? != 0,
113 observed_at: row.get(5)?,
114 })
115 })?
116 .collect::<Result<Vec<_>, _>>()?;
117 Ok(rows)
118 }
119
120 pub fn project_instructions_forget(&self, agent_id: &str) -> Result<usize, StoreError> {
133 let conn = self.conn.lock().unwrap();
134 Ok(conn.execute(
135 "DELETE FROM db_agent_project_instructions WHERE agent_id = ?1",
136 params![agent_id],
137 )?)
138 }
139}
140
141pub fn classify_change(
147 previous: Option<&ProjectInstructionObservation>,
148 current_exists: bool,
149 current_hash: &str,
150) -> InstructionChange {
151 let Some(prev) = previous else {
152 return InstructionChange::FirstSeen;
153 };
154 match (prev.existed, current_exists) {
155 (false, false) => InstructionChange::Unchanged,
156 (false, true) => InstructionChange::Added,
157 (true, false) => InstructionChange::Removed,
158 (true, true) => {
159 if current_hash.is_empty() || prev.content_hash.is_empty() {
165 InstructionChange::Unchanged
166 } else if prev.content_hash == current_hash {
167 InstructionChange::Unchanged
168 } else {
169 InstructionChange::Modified
170 }
171 }
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 fn obs(path: &str, hash: &str, existed: bool) -> ProjectInstructionObservation {
180 ProjectInstructionObservation {
181 path: path.to_string(),
182 content_hash: hash.to_string(),
183 size_bytes: 1,
184 owner: "foreign".to_string(),
185 existed,
186 observed_at: 1,
187 }
188 }
189
190 #[test]
191 fn recording_twice_replaces_rather_than_duplicates() {
192 let s = Store::open_in_memory().unwrap();
193 s.project_instructions_record("a1", &[obs("CLAUDE.md", "h1", true)]).unwrap();
194 s.project_instructions_record("a1", &[obs("CLAUDE.md", "h2", true)]).unwrap();
195
196 let rows = s.project_instructions_list("a1").unwrap();
197 assert_eq!(rows.len(), 1, "one row per (agent, path)");
198 assert_eq!(rows[0].content_hash, "h2", "latest observation wins");
199 }
200
201 #[test]
202 fn observations_are_scoped_to_their_agent() {
203 let s = Store::open_in_memory().unwrap();
204 s.project_instructions_record("a1", &[obs("CLAUDE.md", "h1", true)]).unwrap();
205 s.project_instructions_record("a2", &[obs("CLAUDE.md", "other", true)]).unwrap();
206 assert_eq!(s.project_instructions_list("a1").unwrap()[0].content_hash, "h1");
207 assert_eq!(s.project_instructions_list("a2").unwrap()[0].content_hash, "other");
208 }
209
210 #[test]
211 fn forgetting_one_agent_leaves_the_others_alone() {
212 let s = Store::open_in_memory().unwrap();
213 s.project_instructions_record("a1", &[obs("CLAUDE.md", "h1", true)]).unwrap();
214 s.project_instructions_record("a2", &[obs("CLAUDE.md", "h1", true)]).unwrap();
215 assert_eq!(s.project_instructions_forget("a1").unwrap(), 1);
216 assert!(s.project_instructions_list("a1").unwrap().is_empty());
217 assert_eq!(s.project_instructions_list("a2").unwrap().len(), 1);
218 }
219
220 #[test]
221 fn deleting_an_agent_forgets_its_observations() {
222 let s = Store::open_in_memory().unwrap();
226 let mut def: crate::backend::storage::AgentDefinition =
227 serde_json::from_value(serde_json::json!({
228 "id": "doomed",
229 "slug": "doomed",
230 "name": "Doomed",
231 "icon": "robot",
232 "provider": "claude",
233 "description": "",
234 "working_directory": "",
235 "created_at": 1,
236 }))
237 .unwrap();
238 s.agent_def_insert(&mut def).unwrap();
239 s.project_instructions_record("doomed", &[obs("CLAUDE.md", "h1", true)]).unwrap();
240 s.project_instructions_record("survivor", &[obs("CLAUDE.md", "h1", true)]).unwrap();
241
242 assert!(s.agent_def_delete("doomed").unwrap());
243 assert!(
244 s.project_instructions_list("doomed").unwrap().is_empty(),
245 "a deleted agent must not leave observations behind"
246 );
247 assert_eq!(
248 s.project_instructions_list("survivor").unwrap().len(),
249 1,
250 "and must not take anyone else's with it"
251 );
252 }
253
254 #[test]
255 fn classify_covers_every_transition() {
256 let present = obs("CLAUDE.md", "h1", true);
257 let absent = obs("CLAUDE.md", "", false);
258
259 assert_eq!(classify_change(None, true, "h1"), InstructionChange::FirstSeen);
260 assert_eq!(classify_change(Some(&present), true, "h1"), InstructionChange::Unchanged);
261 assert_eq!(classify_change(Some(&present), true, "h2"), InstructionChange::Modified);
262 assert_eq!(classify_change(Some(&present), false, ""), InstructionChange::Removed);
263 assert_eq!(classify_change(Some(&absent), true, "h1"), InstructionChange::Added);
264 assert_eq!(classify_change(Some(&absent), false, ""), InstructionChange::Unchanged);
265 }
266
267 #[test]
268 fn an_unreadable_file_is_not_reported_as_modified() {
269 let present = obs("CLAUDE.md", "h1", true);
273 assert_eq!(classify_change(Some(&present), true, ""), InstructionChange::Unchanged);
274
275 let unreadable_before = obs("CLAUDE.md", "", true);
276 assert_eq!(
277 classify_change(Some(&unreadable_before), true, "h1"),
278 InstructionChange::Unchanged,
279 "no baseline to compare against either"
280 );
281 }
282}