Skip to main content

agentmux_srv/backend/storage/
project_instructions.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Tracking for what an agent reads as project instructions.
5//!
6//! Phase 3 of `SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md`. The
7//! resolver in `backend::project_instructions` answers "what will this agent
8//! read, right now". This records those answers so the next one can be
9//! compared against the last, which is what makes a repository's own
10//! `CLAUDE.md` changing under a running agent visible at all.
11//!
12//! **Hash and metadata only, never content.** `db_agent_native_memory` mirrors
13//! content because it is a durable copy of files that can become unreachable;
14//! these files belong to the repository, are always readable from it, and are
15//! never written by AgentMux. A second copy of somebody else's file, with
16//! nothing keeping it honest, would be a liability rather than a feature.
17
18use rusqlite::params;
19use serde::Serialize;
20
21use super::error::StoreError;
22use super::store::Store;
23
24/// One recorded observation of one instruction file.
25#[derive(Debug, Clone, Serialize)]
26pub struct ProjectInstructionObservation {
27    pub path: String,
28    pub content_hash: String,
29    pub size_bytes: i64,
30    /// `"agentmux"` or `"foreign"`, as the resolver classified it.
31    pub owner: String,
32    pub existed: bool,
33    pub observed_at: i64,
34}
35
36/// How a file compares to the last time it was observed.
37#[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    /// No prior observation — the first time this agent was looked at.
42    FirstSeen,
43    Unchanged,
44    /// Content differs from the last observation.
45    Modified,
46    /// Present now, absent before.
47    Added,
48    /// Absent now, present before.
49    Removed,
50}
51
52impl Store {
53    /// Record what was observed, replacing the previous observation.
54    ///
55    /// Last-write-wins per `(agent_id, path)`: this is a "what did it look
56    /// like most recently" record, not a history. Version history for these
57    /// files would mean storing their content, which is exactly what this
58    /// table declines to do — the repository's own version control already
59    /// owns that job for the repository's own files.
60    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    /// Every recorded observation for `agent_id`, path-ordered.
94    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    /// Forget everything recorded for `agent_id`.
121    ///
122    /// Called by `agent_def_delete`, beside the row these observations belong
123    /// to. There is no foreign key to enforce it — they record files an agent
124    /// *reads*, which is not a relationship SQLite can express — so without
125    /// that call the rows outlive the agent and a future agent reusing the id
126    /// inherits somebody else's baseline, every file reporting `unchanged`
127    /// against observations never made about it (ReAgent, PR #3162).
128    ///
129    /// Nothing else should call it: dropping observations resets every file to
130    /// `FirstSeen`, which silently discards the drift this table exists to
131    /// surface.
132    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
141/// Compare a fresh observation against the recorded one.
142///
143/// Free function rather than a `Store` method: it is pure, and keeping it that
144/// way means the comparison can be unit-tested without a database and reused
145/// wherever both halves are already in hand.
146pub 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            // An empty hash means the file could not be read this time — a
160            // permissions change, or bytes that stopped being UTF-8. Calling
161            // that "modified" would be a guess; calling it unchanged would
162            // hide it. The resolver already reports the error alongside, so
163            // the honest answer here is that nothing is known to have changed.
164            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        // Without this the rows outlive the agent, and an agent reusing the id
223        // inherits a baseline that was never about it — every file reporting
224        // `unchanged` against somebody else's observations (ReAgent, #3162).
225        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        // The resolver leaves the hash empty when it cannot read the bytes.
270        // Guessing "modified" from that would cry wolf on a permissions
271        // change; the resolver reports the error separately.
272        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}