agentmux_srv\backend\storage/registry_mirror.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Cross-version named-agent registry mirror.
5//!
6//! Mirrors `db_agent_instances` mutations into the JSON registry at
7//! `~/.agentmux/agents/registry/` so other AgentMux versions running
8//! on the same machine see each other's named agents in their launch
9//! modal dropdowns. SQLite is authoritative for the local version;
10//! the registry is a best-effort cross-version view.
11//!
12//! Extracted from `store.rs` in Phase R.6 of the storage
13//! modularization plan
14//! (`docs/specs/SPEC_STORE_MODULARIZATION_2026_05_27.md`). This whole
15//! file retires when Phase R sunsets the JSON registry entirely;
16//! isolating it now makes that future deletion a clean unit.
17
18use std::path::Path;
19
20use super::store::{AgentInstance, Store};
21
22/// Build a registry record from an `AgentInstance`. Returns an error
23/// if the working directory can't be expressed as a path relative to
24/// the canonical shared agents root (e.g. user pointed an agent at
25/// `~/projects/foo`, which would also fail a naive `"agents"`
26/// segment-scan that happened to match `~/projects/agents/foo`).
27/// Caller logs + skips — agent stays in SQLite, just not in the
28/// cross-version dropdown.
29fn agent_instance_to_record(
30 inst: &AgentInstance,
31 global_agents_root: Option<&Path>,
32 channel_agents_base: &Path,
33) -> Result<crate::registry::NamedAgentRecord, String> {
34 use crate::registry::{NamedAgentRecord, NamedAgentRecordV1};
35 // Agent workspaces are GLOBAL (`<home>/agents/<name>`), so anchor on that
36 // global root FIRST, falling back to this channel's agents dir only for a
37 // legacy in-channel workspace. This MUST match `registry/migrate.rs`'s
38 // `row_to_record` — the one-shot migration and this live mirror have to stamp
39 // the same `source_agents_base` for a given agent or a reader sees two
40 // different bases. Without the global branch, every real (global) workspace
41 // failed strip_prefix and the agent was dropped from the registry ("not
42 // representable") — the live-write twin of the bug #1393 fixed in the
43 // migration.
44 let (rel, base): (String, &Path) = global_agents_root
45 .and_then(|g| relative_workdir(&inst.working_directory, g).map(|r| (r, g)))
46 .or_else(|| {
47 relative_workdir(&inst.working_directory, channel_agents_base)
48 .map(|r| (r, channel_agents_base))
49 })
50 .ok_or_else(|| {
51 format!(
52 "working_directory {:?} is under neither the global agents root {:?} nor the channel base {:?}",
53 inst.working_directory,
54 global_agents_root.map(|p| p.display().to_string()),
55 channel_agents_base.display()
56 )
57 })?;
58 let version = env!("CARGO_PKG_VERSION").to_string();
59 let data = NamedAgentRecordV1 {
60 instance_id: inst.id.clone(),
61 instance_name: inst.instance_name.clone(),
62 definition_id: inst.definition_id.clone(),
63 identity_id: empty_to_none(&inst.identity_id),
64 memory_id: empty_to_none(&inst.memory_id),
65 // v2: carry the provider session id so a global/cross-channel
66 // record can `--resume` without joining the current channel's SQLite.
67 session_id: empty_to_none(&inst.session_id),
68 working_dir: rel,
69 // v3: the agents dir `working_dir` is relative to — the GLOBAL workspace
70 // root for a normal agent, or this channel's agents dir for a legacy
71 // in-channel workspace. Stored absolute so any channel reconstructs the
72 // path via `source_agents_base.join(working_dir)` (P0.4).
73 source_agents_base: Some(base.to_string_lossy().to_string()),
74 created_at_ms: inst.created_at,
75 last_launched_at_ms: inst.started_at,
76 created_by_version: version.clone(),
77 last_launched_by_version: version,
78 };
79 // Stamp the lowest envelope schema that faithfully represents the payload
80 // (schema::min_schema_version). Since P0.4 always sets source_agents_base,
81 // that is v3 for every live-mirrored record — a pre-v3 reader rejects it
82 // (intended: better to hide than mis-resolve a cross-channel workdir; the
83 // only pre-v3 readers of the GLOBAL registry are pre-P0.4 builds, which
84 // ship together with this).
85 Ok(NamedAgentRecord {
86 schema_version: data.min_schema_version(),
87 data,
88 })
89}
90
91fn empty_to_none(s: &str) -> Option<String> {
92 if s.is_empty() {
93 None
94 } else {
95 Some(s.to_string())
96 }
97}
98
99/// Express `abs` as a path relative to `agents_root`. Returns `None`
100/// when `abs` is empty, not under `agents_root`, or after stripping
101/// resolves to an empty path. Anchors against the **resolved** shared
102/// root (passed in by the caller) — never scans for a path segment
103/// named "agents", which would match unrelated user directories like
104/// `/home/me/projects/agents/foo`.
105fn relative_workdir(abs: &str, agents_root: &Path) -> Option<String> {
106 if abs.is_empty() {
107 return None;
108 }
109 let p = std::path::Path::new(abs);
110 let rel = p.strip_prefix(agents_root).ok()?;
111 // Reject empties + traversals (defense in depth — strip_prefix
112 // already rules out `..` escapes, but the registry's own validator
113 // re-checks).
114 let s = rel.to_string_lossy().to_string();
115 if s.is_empty() || s == "." {
116 return None;
117 }
118 Some(s)
119}
120
121impl Store {
122 /// Mirror a `db_agent_instances` mutation into the cross-version
123 /// registry. Only fires for **named** rows. Routes by
124 /// `display_hidden` so the registry file ends up in the tree
125 /// matching SQLite's dropdown filter:
126 ///
127 /// - hidden = true → upsert (atomic write to active/) then
128 /// retire (atomic rename to retired/). Net: file lives in
129 /// `retired/<id>.json` with the freshest content. Prevents
130 /// `instance_update` on a previously-hidden row from
131 /// resurrecting an active registry file, AND keeps the
132 /// retired tombstone's content current.
133 /// - hidden = false → unretire (no-op if not retired) then
134 /// upsert. Net: file in `active/<id>.json`, no orphan retired.
135 ///
136 /// Failures are logged, never propagated: SQLite remains
137 /// authoritative.
138 pub(super) fn registry_upsert_if_named(&self, inst: &AgentInstance) {
139 // Mirror filter: only registers named rows. Pre-Option-E this
140 // also excluded continuation rows (parent_instance_id != '')
141 // so the registry-sourced read path wouldn't surface chained
142 // resumes as duplicate dropdown rows. Under Option E, the
143 // session zone is anchored on definition_id, so a continuation
144 // row IS the most-recent named instance — exactly what we want
145 // visible. `instance_list_named` (the SQLite-sourced read
146 // path) dropped its parent_instance_id filter in the
147 // 2026-05-24 picker-visibility fix; the registry mirror keeps
148 // its filter here for now since the registry-sourced read path
149 // doesn't have the dedup-by-(definition_id, instance_name)
150 // affordance the SQLite ORDER BY/LIMIT provides. Follow-up
151 // PR will land cross-version dedup so this filter can drop too.
152 if inst.instance_name.is_empty() || !inst.parent_instance_id.is_empty() {
153 return;
154 }
155 let Some(reg) = self.shared_agent_registry() else {
156 return;
157 };
158 // Anchor the relative working_directory on the CURRENT channel's
159 // agents dir, not the registry's own parent — once P0.3 re-roots the
160 // registry under ~/.agentmux/shared/, its parent no longer coincides
161 // with channels/<ch>/agents/. `registry_agents_base()` returns the
162 // explicit channel dir (AGENTMUX_AGENTS_DIR) and falls back to the
163 // registry parent for tests / pre-re-root layouts.
164 let Some(agents_root) = self.registry_agents_base() else {
165 tracing::warn!("registry: no agents base — skipping mirror");
166 return;
167 };
168 // Agent workspaces are GLOBAL (`<home>/agents/<name>`). Derive `<home>`
169 // from the registry root exactly as main.rs does for the one-shot
170 // migration (registry = `<home>/shared/agents/registry` → `nth(3)` strips
171 // registry→agents→shared = `<home>`), so the live mirror and the migration
172 // anchor identically. `None` in shallow test/pre-re-root layouts → the
173 // mirror falls back to the per-channel base.
174 let global_agents_root = reg.root().ancestors().nth(3).map(|h| h.join("agents"));
175 let rec = match agent_instance_to_record(inst, global_agents_root.as_deref(), &agents_root) {
176 Ok(rec) => rec,
177 Err(e) => {
178 tracing::warn!(
179 instance_id = %inst.id,
180 error = %e,
181 "registry: instance not representable as record, skipping mirror"
182 );
183 return;
184 }
185 };
186
187 // If the row was previously hidden, the file lives in
188 // `retired/`. Move it back to active before upserting so
189 // upsert's merge-preserves-unknown-fields path operates on
190 // the right file (and we never leave dangling retired files).
191 if let Err(e) = reg.unretire(&inst.id) {
192 tracing::warn!(
193 instance_id = %inst.id,
194 error = %e,
195 "registry: failed to unretire row before upsert"
196 );
197 }
198
199 if let Err(e) = reg.upsert(&rec) {
200 tracing::warn!(
201 instance_id = %inst.id,
202 error = %e,
203 "registry: failed to mirror instance_create/update"
204 );
205 return;
206 }
207
208 // After the upsert, move into retired/ if the row is hidden.
209 // Combined: hidden row's tombstone always has up-to-date
210 // content, and active/ never carries a hidden row.
211 if inst.display_hidden {
212 if let Err(e) = reg.retire(&inst.id) {
213 tracing::warn!(
214 instance_id = %inst.id,
215 error = %e,
216 "registry: failed to retire hidden row post-upsert"
217 );
218 }
219 }
220 }
221}