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}