Skip to main content

agentmux_srv/registry/
store.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `Registry` — file-per-agent CRUD on `<root>/<uuid>.json` with
5//! a sibling `retired/<uuid>.json` tombstone tree.
6//!
7//! Concurrency: cross-process safety comes from filesystem atomic
8//! rename. The internal `Mutex` only serializes partial-merge writes
9//! from threads inside the same `srv` so a v1 binary touching
10//! `last_launched_at_ms` doesn't race itself.
11
12use std::path::{Path, PathBuf};
13use std::sync::Mutex;
14
15use serde_json::Value;
16use thiserror::Error;
17
18use super::atomic::{rename_atomic, write_atomic};
19use super::schema::{validate, NamedAgentRecord, ValidationError, MAX_SUPPORTED_SCHEMA};
20
21#[derive(Debug, Error)]
22pub enum RegistryError {
23    #[error("io: {0}")]
24    Io(#[from] std::io::Error),
25    #[error("json: {0}")]
26    Json(#[from] serde_json::Error),
27    #[error("validation: {0}")]
28    Validation(#[from] ValidationError),
29}
30
31/// Which records an operation is allowed to touch.
32///
33/// The registry has two ways to say "this record belongs to X": the file
34/// key (`<instance_id>.json`) and the record's own `definition_id`. Since
35/// the agent-concept consolidation those are the same value for an agent —
36/// but only for records `m0026_registry_agent_id_rekey` re-keyed, and that
37/// pass no-ops once `db_agent_instances` is dropped (schema v32), so a real
38/// install still holds launch-keyed records where they differ. Matching
39/// both is what makes Delete actually remove an agent's row
40/// ([`Registry::hard_delete_for_agent`]).
41///
42/// It is also what makes a TEMPLATE dangerous: a template's id is the
43/// `definition_id` of every legacy launch record for the real agents
44/// launched from it, so a wide match would delete/retire/rename THEIR
45/// records along with the template. Hence this type rather than a `bool`
46/// or a per-call-site `if` — ReAgent found the same omission at four
47/// separate call sites on PR #3262 (rounds 3 and 4), which is a sign the
48/// decision belongs in the signature, not in each caller's memory.
49#[derive(Clone, Copy, PartialEq, Eq, Debug)]
50pub enum RecordScope {
51    /// Only the record whose FILE KEY is this id. The correct scope for a
52    /// template: its own record, and nothing belonging to its launches.
53    FileKeyOnly,
54    /// Every record for this agent — file key or `definition_id`.
55    Agent,
56}
57
58pub struct Registry {
59    root: PathBuf,
60    write_lock: Mutex<()>,
61}
62
63impl Registry {
64    /// Open or create the registry rooted at `root`. Ensures the
65    /// active dir and `retired/` subdir both exist.
66    pub fn open(root: PathBuf) -> Result<Self, RegistryError> {
67        std::fs::create_dir_all(&root)?;
68        std::fs::create_dir_all(root.join("retired"))?;
69        Ok(Self {
70            root,
71            write_lock: Mutex::new(()),
72        })
73    }
74
75    pub fn root(&self) -> &Path {
76        &self.root
77    }
78
79    /// Resolved `<shared_home>/agents/` — one level above `root`.
80    /// Used by callers that need to express working-directory paths
81    /// as relative subpaths under the shared agents tree. Returns
82    /// `None` if the registry root has no parent (only happens in
83    /// pathological filesystem-root setups; production always nests
84    /// under `~/.agentmux/agents/registry`).
85    pub fn agents_root(&self) -> Option<&Path> {
86        self.root.parent()
87    }
88
89    fn active_path(&self, instance_id: &str) -> PathBuf {
90        self.root.join(format!("{instance_id}.json"))
91    }
92
93    fn retired_path(&self, instance_id: &str) -> PathBuf {
94        self.root.join("retired").join(format!("{instance_id}.json"))
95    }
96
97    /// Insert or update a record. If the file already exists, unknown
98    /// top-level + `data` fields are preserved (forward-compat with
99    /// future schemas that add columns this binary doesn't know).
100    ///
101    /// Forward-compat invariant (spec §6): never write a higher-schema
102    /// row into a lower schema, never overwrite a corrupt/unparseable
103    /// file. Both cases skip the mirror with a warning — the on-disk
104    /// file stays intact for the binary that authored it (or for ops
105    /// triage). Skipping is `Ok(())`: SQLite remains authoritative.
106    pub fn upsert(&self, rec: &NamedAgentRecord) -> Result<(), RegistryError> {
107        let _g = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
108        self.upsert_unlocked(rec, SessionWrite::Overwrite)
109    }
110
111    /// [`Self::upsert`], except a `session_id` the record already holds
112    /// wins over `rec`'s. For a mirror that didn't capture a session — a
113    /// launch — whose local row may carry an older session id than the one
114    /// another channel has since written here (#3586). A record without a
115    /// session id still takes `rec`'s.
116    pub fn upsert_keeping_session(&self, rec: &NamedAgentRecord) -> Result<(), RegistryError> {
117        let _g = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
118        self.upsert_unlocked(rec, SessionWrite::KeepExisting)
119    }
120
121    /// [`Self::upsert`] without taking `write_lock`, for callers already
122    /// holding it. `write_lock` is a plain `Mutex` — re-entering it from a
123    /// locked section would deadlock, not nest.
124    fn upsert_unlocked(&self, rec: &NamedAgentRecord, session: SessionWrite) -> Result<(), RegistryError> {
125        let path = self.active_path(&rec.data.instance_id);
126        let bytes = match std::fs::read(&path) {
127            Ok(existing) => match merge_for_write(&existing, rec, session)? {
128                Some(b) => b,
129                None => return Ok(()),
130            },
131            Err(e) if e.kind() == std::io::ErrorKind::NotFound => to_pretty(rec)?,
132            Err(e) => return Err(e.into()),
133        };
134        write_atomic(&path, &bytes)?;
135        Ok(())
136    }
137
138    /// Set `instance_name` on every ACTIVE record for `agent_id`. Returns
139    /// how many files changed.
140    ///
141    /// Every record, not just the canonical one, because
142    /// `listrecentsessions` dedupes rows by `(definition_id, instance_name)`
143    /// — renaming one of an agent's records and not the others splits it
144    /// into two picker rows, one under each name. (An agent can hold more
145    /// than one record for the same reason
146    /// [`Self::hard_delete_for_agent`] exists.)
147    ///
148    /// Active tree only: `upsert` writes to the active path, so touching a
149    /// retired record here would resurrect a deliberately-forgotten agent.
150    /// A retired record keeps its old name until `unretire_for_agent`
151    /// brings it back.
152    pub fn set_instance_name_for_agent(
153        &self,
154        agent_id: &str,
155        scope: RecordScope,
156        instance_name: &str,
157    ) -> Result<usize, RegistryError> {
158        let _g = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
159        let mut renamed = 0usize;
160        for path in records_for_agent(&self.root, agent_id, scope)? {
161            let Ok(bytes) = std::fs::read(&path) else {
162                continue;
163            };
164            let Ok(mut rec) = serde_json::from_slice::<NamedAgentRecord>(&bytes) else {
165                continue;
166            };
167            // An unnamed record fails validation on READ, so it is already
168            // invisible to the picker — but `upsert` doesn't validate, so
169            // one can sit on disk, and this scan reads raw JSON (matching on
170            // `definition_id` is the whole point). Naming it here would
171            // "repair" it into a visible row for an agent that never had
172            // one. Pinned by
173            // `set_instance_name_for_agent_leaves_an_unnamed_record_invisible`.
174            if rec.data.instance_name.is_empty() || rec.data.instance_name == instance_name {
175                continue;
176            }
177            rec.data.instance_name = instance_name.to_string();
178            self.upsert_unlocked(&rec, SessionWrite::Overwrite)?;
179            renamed += 1;
180        }
181        Ok(renamed)
182    }
183
184    /// Move record into `retired/` (soft delete — keeps the working
185    /// dir intact, drops it from the launch-modal dropdown). Idempotent.
186    pub fn retire(&self, instance_id: &str) -> Result<(), RegistryError> {
187        let _g = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
188        let from = self.active_path(instance_id);
189        if !from.exists() {
190            return Ok(());
191        }
192        rename_atomic(&from, &self.retired_path(instance_id))?;
193        Ok(())
194    }
195
196    /// Move record back from `retired/` to active. Idempotent.
197    pub fn unretire(&self, instance_id: &str) -> Result<(), RegistryError> {
198        let _g = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
199        let from = self.retired_path(instance_id);
200        if !from.exists() {
201            return Ok(());
202        }
203        rename_atomic(&from, &self.active_path(instance_id))?;
204        Ok(())
205    }
206
207    /// Hard-delete by FILE KEY only (drops both active and retired files),
208    /// returning how many files were removed.
209    ///
210    /// The narrow counterpart of [`Self::hard_delete_for_agent`]. Deleting a
211    /// TEMPLATE needs this one: a template's id can legitimately appear as
212    /// the `definition_id` of a legacy, never-re-keyed launch record
213    /// belonging to a real agent launched from it, and matching on
214    /// `definition_id` would take that agent's record along with the
215    /// template (ReAgent P1 round 3 on PR #3262). A template's own record,
216    /// if it has one, is file-keyed, so this is the complete sweep for one.
217    pub fn hard_delete(&self, instance_id: &str) -> Result<usize, RegistryError> {
218        let _g = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
219        let mut removed = 0usize;
220        for p in [self.active_path(instance_id), self.retired_path(instance_id)] {
221            match std::fs::remove_file(&p) {
222                Ok(()) => removed += 1,
223                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
224                Err(e) => return Err(e.into()),
225            }
226        }
227        Ok(removed)
228    }
229
230    /// Drop every record — active AND retired — that belongs to the agent
231    /// `agent_id`, matching on either the record's file key (its instance
232    /// id) or its `definition_id`. Returns how many files were removed.
233    ///
234    /// **Why this exists alongside [`Self::hard_delete`].** That one keys
235    /// only on the file name. Since the agent-concept consolidation an
236    /// agent's instance id and definition id are the same value, so the two
237    /// agree — but only for records this tree has been re-keyed to.
238    /// `m0026_registry_agent_id_rekey` does that re-keying by reading
239    /// `db_agent_instances`, and returns a no-op the moment that legacy
240    /// table is gone (v32 dropped it). An install that consolidated before
241    /// m0026 could run therefore still holds one *launch*-keyed file per
242    /// agent, in addition to the correctly-keyed one.
243    ///
244    /// Deleting such an agent removed only the correctly-keyed file,
245    /// leaving the launch-keyed one active with no definition behind it —
246    /// which `listrecentsessions` still renders as a row (no provider, so
247    /// no icon, and `"(missing definition)"`). The delete looked like it
248    /// half-worked: the icon vanished, the card stayed.
249    ///
250    /// Both keys are checked rather than just `definition_id` because
251    /// callers legitimately hold either one: a consolidated agent id, or
252    /// the launch id of a registry-only row this channel's SQLite has never
253    /// seen. Matching both is a strict superset of [`Self::hard_delete`],
254    /// and a cross-key collision would require the same uuid to name two
255    /// different agents.
256    pub fn hard_delete_for_agent(
257        &self,
258        agent_id: &str,
259        scope: RecordScope,
260    ) -> Result<usize, RegistryError> {
261        let _g = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
262        let mut removed = 0usize;
263        for dir in [self.root.clone(), self.root.join("retired")] {
264            for path in records_for_agent(&dir, agent_id, scope)? {
265                match std::fs::remove_file(&path) {
266                    Ok(()) => removed += 1,
267                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
268                    Err(e) => return Err(e.into()),
269                }
270            }
271        }
272        Ok(removed)
273    }
274
275    /// Retire every active record for `agent_id`, matched the same way
276    /// [`Self::hard_delete_for_agent`] matches. A launch-keyed record left
277    /// active by the file-keyed [`Self::retire`] is exactly the "'Forget
278    /// agent' retires the NEW key while the stale file stays active, so the
279    /// forgotten agent reappears" failure `m0026_registry_agent_id_rekey`'s
280    /// own doc comment predicted.
281    pub fn retire_for_agent(&self, agent_id: &str, scope: RecordScope) -> Result<usize, RegistryError> {
282        let _g = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
283        self.move_for_agent(agent_id, scope, self.root.clone(), self.root.join("retired"))
284    }
285
286    /// Inverse of [`Self::retire_for_agent`].
287    pub fn unretire_for_agent(&self, agent_id: &str, scope: RecordScope) -> Result<usize, RegistryError> {
288        let _g = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
289        self.move_for_agent(agent_id, scope, self.root.join("retired"), self.root.clone())
290    }
291
292    /// Move an agent's records between the active and retired trees,
293    /// keeping each file's own name (the instance id) so the record stays
294    /// addressable by [`Self::get`]. Caller holds `write_lock`.
295    fn move_for_agent(
296        &self,
297        agent_id: &str,
298        scope: RecordScope,
299        from_dir: PathBuf,
300        to_dir: PathBuf,
301    ) -> Result<usize, RegistryError> {
302        let mut moved = 0usize;
303        for path in records_for_agent(&from_dir, agent_id, scope)? {
304            let Some(name) = path.file_name() else {
305                continue;
306            };
307            rename_atomic(&path, &to_dir.join(name))?;
308            moved += 1;
309        }
310        Ok(moved)
311    }
312
313    /// Whether an active record exists for `instance_id`. Doesn't
314    /// validate — useful for migration idempotency checks. Use
315    /// [`Self::exists_anywhere`] when retired records should also
316    /// count (e.g. so migration doesn't resurrect a hidden agent).
317    pub fn exists(&self, instance_id: &str) -> bool {
318        self.active_path(instance_id).exists()
319    }
320
321    /// Read a single active record by id, without scanning the whole
322    /// tree. `Ok(None)` when there's no active file for `instance_id`
323    /// (never checks `retired/`). Callers that need to patch a single
324    /// field (e.g. propagating a fresh `session_id`) must read-modify-
325    /// upsert via this rather than constructing a partial record —
326    /// `upsert`'s merge overwrites every field present in the struct,
327    /// so a partially-populated record would clobber the rest with
328    /// defaults.
329    pub fn get(&self, instance_id: &str) -> Result<Option<NamedAgentRecord>, RegistryError> {
330        let path = self.active_path(instance_id);
331        if !path.exists() {
332            return Ok(None);
333        }
334        Ok(Some(read_and_validate(&path, instance_id)?))
335    }
336
337    /// Whether a record exists in either active or retired. Used by
338    /// migration to skip already-tombstoned records (avoid
339    /// resurrecting a user's deliberate "Forget agent" via a
340    /// per-version SQLite row that still has `display_hidden = 0`).
341    pub fn exists_anywhere(&self, instance_id: &str) -> bool {
342        self.active_path(instance_id).exists() || self.retired_path(instance_id).exists()
343    }
344
345    /// Read every valid active record. Invalid files are skipped +
346    /// logged. PR A doesn't wire this into the RPC path; included so
347    /// PR B can swap reads over without further restructuring.
348    pub fn list_active(&self) -> Result<Vec<NamedAgentRecord>, RegistryError> {
349        let mut out = Vec::new();
350        for entry in std::fs::read_dir(&self.root)? {
351            let entry = entry?;
352            let path = entry.path();
353            if !path.is_file() {
354                continue;
355            }
356            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
357                continue;
358            };
359            if path.extension().and_then(|s| s.to_str()) != Some("json") {
360                continue;
361            }
362            match read_and_validate(&path, stem) {
363                Ok(rec) => out.push(rec),
364                Err(e) => {
365                    tracing::warn!(
366                        file = %path.display(),
367                        error = %e,
368                        "registry: skipping invalid record"
369                    );
370                }
371            }
372        }
373        Ok(out)
374    }
375}
376
377/// Paths of the `*.json` files under `dir` whose record belongs to
378/// `agent_id` — by file key (instance id) or by `definition_id`. A missing
379/// directory yields nothing (the `retired/` tree is created on open, but a
380/// caller may point at a tree that predates it).
381///
382/// Unparseable files are matched on their file key alone and otherwise
383/// skipped, the same way [`Registry::list_active`] skips them: a record
384/// this can't read is also one that can never surface as a row, and
385/// removing it on a guess would destroy whatever newer-schema binary
386/// authored it. The file-key match is kept so an explicitly-addressed
387/// delete still behaves like [`Registry::hard_delete`] did.
388fn records_for_agent(
389    dir: &Path,
390    agent_id: &str,
391    scope: RecordScope,
392) -> Result<Vec<PathBuf>, RegistryError> {
393    let mut out = Vec::new();
394    if scope == RecordScope::FileKeyOnly {
395        let p = dir.join(format!("{agent_id}.json"));
396        if p.is_file() {
397            out.push(p);
398        }
399        return Ok(out);
400    }
401    let entries = match std::fs::read_dir(dir) {
402        Ok(e) => e,
403        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
404        Err(e) => return Err(e.into()),
405    };
406    for entry in entries {
407        let path = entry?.path();
408        if !path.is_file() || path.extension().and_then(|s| s.to_str()) != Some("json") {
409            continue;
410        }
411        if path.file_stem().and_then(|s| s.to_str()) == Some(agent_id) {
412            out.push(path);
413            continue;
414        }
415        let Ok(bytes) = std::fs::read(&path) else {
416            continue;
417        };
418        let Ok(rec) = serde_json::from_slice::<NamedAgentRecord>(&bytes) else {
419            continue;
420        };
421        if rec.data.definition_id == agent_id {
422            out.push(path);
423        }
424    }
425    Ok(out)
426}
427
428fn read_and_validate(path: &Path, stem: &str) -> Result<NamedAgentRecord, RegistryError> {
429    let bytes = std::fs::read(path)?;
430    let rec: NamedAgentRecord = serde_json::from_slice(&bytes)?;
431    validate(stem, &rec)?;
432    Ok(rec)
433}
434
435fn to_pretty(rec: &NamedAgentRecord) -> Result<Vec<u8>, serde_json::Error> {
436    let mut bytes = serde_json::to_vec_pretty(rec)?;
437    bytes.push(b'\n');
438    Ok(bytes)
439}
440
441/// Merge an in-memory record into an on-disk file's raw JSON,
442/// preserving fields beyond this binary's struct shape.
443///
444/// Returns `Ok(None)` when the writer must refuse the merge (corrupt
445/// JSON, missing `schema_version`, or `schema_version` above
446/// `MAX_SUPPORTED_SCHEMA`). The caller treats `None` as a skip and
447/// leaves the on-disk file intact.
448/// Whether an upsert may replace the record's `session_id`.
449#[derive(Clone, Copy, PartialEq, Eq)]
450enum SessionWrite {
451    Overwrite,
452    KeepExisting,
453}
454
455fn merge_for_write(
456    existing: &[u8],
457    rec: &NamedAgentRecord,
458    session: SessionWrite,
459) -> Result<Option<Vec<u8>>, RegistryError> {
460    let on_disk: Value = match serde_json::from_slice(existing) {
461        Ok(v) => v,
462        Err(e) => {
463            tracing::warn!(
464                error = %e,
465                "registry: existing file is unparseable JSON — refusing to overwrite (may be a newer schema)"
466            );
467            return Ok(None);
468        }
469    };
470    // Use `try_from` so any number above `u32::MAX` is treated as
471    // unparseable rather than wrapping into the supported range. A
472    // wrap would let an oversized envelope downgrade-bypass the
473    // forward-compat guard below.
474    let on_disk_version = on_disk
475        .get("schema_version")
476        .and_then(|v| v.as_u64())
477        .and_then(|v| u32::try_from(v).ok());
478    match on_disk_version {
479        Some(v) if v > MAX_SUPPORTED_SCHEMA => {
480            tracing::warn!(
481                on_disk = v,
482                writer_max = MAX_SUPPORTED_SCHEMA,
483                "registry: on-disk schema_version > writer max — refusing downgrade"
484            );
485            return Ok(None);
486        }
487        Some(_) => {}
488        None => {
489            tracing::warn!(
490                "registry: existing file lacks schema_version — refusing to overwrite"
491            );
492            return Ok(None);
493        }
494    }
495    let mut updates = serde_json::to_value(rec)?;
496    let held_session = on_disk
497        .get("data")
498        .and_then(|d| d.get("session_id"))
499        .and_then(Value::as_str)
500        .is_some_and(|s| !s.is_empty());
501    if session == SessionWrite::KeepExisting && held_session {
502        if let Some(data) = updates.get_mut("data").and_then(Value::as_object_mut) {
503            data.remove("session_id");
504        }
505    }
506    let mut merged = on_disk;
507    merge_known(&mut merged, &updates);
508    let mut bytes = serde_json::to_vec_pretty(&merged)?;
509    bytes.push(b'\n');
510    Ok(Some(bytes))
511}
512
513/// Overwrite `target`'s top-level keys with `updates`' keys. Keys in
514/// `target` that aren't in `updates` are preserved. Recurses into
515/// the `data` sub-object so future fields inside `data` also survive.
516fn merge_known(target: &mut Value, updates: &Value) {
517    let (Some(t), Some(u)) = (target.as_object_mut(), updates.as_object()) else {
518        *target = updates.clone();
519        return;
520    };
521    for (k, v) in u {
522        if k == "data" {
523            if let Some(t_data) = t.get_mut("data") {
524                merge_known(t_data, v);
525                continue;
526            }
527        }
528        t.insert(k.clone(), v.clone());
529    }
530}