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 /// `wrote_session` says whether the caller's own write set the row's
137 /// `session_id`. When it didn't (a launch), the row's session id may be
138 /// older than one another channel has since mirrored, so a session id
139 /// the registry already holds is kept (#3586).
140 ///
141 /// Failures are logged, never propagated: SQLite remains
142 /// authoritative.
143 pub(super) fn registry_upsert_if_named(&self, inst: &AgentInstance, wrote_session: bool) {
144 // Mirror filter: only registers named rows. Pre-Option-E this
145 // also excluded continuation rows (parent_instance_id != '')
146 // so the registry-sourced read path wouldn't surface chained
147 // resumes as duplicate dropdown rows. Under Option E, the
148 // session zone is anchored on definition_id, so a continuation
149 // row IS the most-recent named instance — exactly what we want
150 // visible. `instance_list_named` (the SQLite-sourced read
151 // path) dropped its parent_instance_id filter in the
152 // 2026-05-24 picker-visibility fix; the registry mirror keeps
153 // its filter here for now since the registry-sourced read path
154 // doesn't have the dedup-by-(definition_id, instance_name)
155 // affordance the SQLite ORDER BY/LIMIT provides. Follow-up
156 // PR will land cross-version dedup so this filter can drop too.
157 if inst.instance_name.is_empty() || !inst.parent_instance_id.is_empty() {
158 return;
159 }
160 let Some(reg) = self.shared_agent_registry() else {
161 return;
162 };
163 // Anchor the relative working_directory on the CURRENT channel's
164 // agents dir, not the registry's own parent — once P0.3 re-roots the
165 // registry under ~/.agentmux/shared/, its parent no longer coincides
166 // with channels/<ch>/agents/. `registry_agents_base()` returns the
167 // explicit channel dir (AGENTMUX_AGENTS_DIR) and falls back to the
168 // registry parent for tests / pre-re-root layouts.
169 let Some(agents_root) = self.registry_agents_base() else {
170 tracing::warn!("registry: no agents base — skipping mirror");
171 return;
172 };
173 // Agent workspaces are GLOBAL (`<home>/agents/<name>`). Derive `<home>`
174 // from the registry root exactly as main.rs does for the one-shot
175 // migration (registry = `<home>/shared/agents/registry` → `nth(3)` strips
176 // registry→agents→shared = `<home>`), so the live mirror and the migration
177 // anchor identically. `None` in shallow test/pre-re-root layouts → the
178 // mirror falls back to the per-channel base.
179 let global_agents_root = reg.root().ancestors().nth(3).map(|h| h.join("agents"));
180 let rec = match agent_instance_to_record(inst, global_agents_root.as_deref(), &agents_root) {
181 Ok(rec) => rec,
182 Err(e) => {
183 tracing::warn!(
184 instance_id = %inst.id,
185 error = %e,
186 "registry: instance not representable as record, skipping mirror"
187 );
188 return;
189 }
190 };
191
192 // If the row was previously hidden, the file lives in
193 // `retired/`. Move it back to active before upserting so
194 // upsert's merge-preserves-unknown-fields path operates on
195 // the right file (and we never leave dangling retired files).
196 if let Err(e) = reg.unretire(&inst.id) {
197 tracing::warn!(
198 instance_id = %inst.id,
199 error = %e,
200 "registry: failed to unretire row before upsert"
201 );
202 }
203
204 let upserted = if wrote_session {
205 reg.upsert(&rec)
206 } else {
207 reg.upsert_keeping_session(&rec)
208 };
209 if let Err(e) = upserted {
210 tracing::warn!(
211 instance_id = %inst.id,
212 error = %e,
213 "registry: failed to mirror instance_create/update"
214 );
215 return;
216 }
217
218 // After the upsert, move into retired/ if the row is hidden.
219 // Combined: hidden row's tombstone always has up-to-date
220 // content, and active/ never carries a hidden row.
221 if inst.display_hidden {
222 if let Err(e) = reg.retire(&inst.id) {
223 tracing::warn!(
224 instance_id = %inst.id,
225 error = %e,
226 "registry: failed to retire hidden row post-upsert"
227 );
228 }
229 }
230 }
231
232 /// Propagate a fresh `session_id` onto the chain-root registry record
233 /// when `inst` is a **continuation** row (`parent_instance_id` set).
234 ///
235 /// `registry_upsert_if_named` above deliberately excludes continuation
236 /// rows from the mirror (see its filter comment — kept symmetric with
237 /// `instance_list_named`'s legacy dropdown mode so a resume chain
238 /// doesn't fragment into N registry files). That's correct for most
239 /// fields, but it leaves the registry's `session_id` — the ONE field a
240 /// cross-channel/cross-build reader consults to `--resume` this agent —
241 /// stuck at whatever the chain head had when first mirrored, never
242 /// updated by any later resume. Root cause of
243 /// docs/retro/retro-agent-resumed-9-day-stale-session-2026-08-22.md:
244 /// reopening the agent in a different channel silently resumed a
245 /// stale-but-valid session instead of the live one.
246 ///
247 /// Read-modify-write against the existing root record (never
248 /// constructs a partial one — `Registry::upsert`'s merge overwrites
249 /// every field present in the struct). Best-effort: logs and returns
250 /// on any lookup/write failure, since the SQLite write already
251 /// succeeded and remains authoritative.
252 ///
253 /// Callers decide WHETHER to invoke this at all based on whether
254 /// `inst.session_id` reflects a genuine write, not just this
255 /// function — an empty `session_id` is ambiguous in isolation: it can
256 /// mean "never captured yet" (a fresh continuation — must NOT
257 /// propagate, would clobber a still-valid root pointer, reagent P1 on
258 /// PR #2755) or "deliberately cleared" (`poison_resume`'s stale-resume
259 /// handling, via `persist_session_id(block_id, "", ...)` on an
260 /// EXISTING row — MUST propagate, otherwise the shared registry keeps
261 /// serving a session_id already proven dead, reagent P1 round 2). Only
262 /// the caller knows which case it's in (create vs. an explicit
263 /// `InstanceUpdate.session_id` write), so this function itself never
264 /// special-cases emptiness — it always mirrors whatever it's given.
265 pub(super) fn registry_propagate_continuation_session_id(&self, inst: &AgentInstance) {
266 if inst.parent_instance_id.is_empty() {
267 return; // chain head — already covered by registry_upsert_if_named.
268 }
269 let Some(reg) = self.shared_agent_registry() else {
270 return;
271 };
272 let root_id = self.find_chain_root_id(inst);
273 if root_id == inst.id {
274 return;
275 }
276 let mut rec = match reg.get(&root_id) {
277 Ok(Some(rec)) => rec,
278 Ok(None) => return, // root was never named/mirrored — nothing to update.
279 Err(e) => {
280 tracing::warn!(
281 instance_id = %inst.id,
282 root_id = %root_id,
283 error = %e,
284 "registry: failed to read chain-root record for session_id propagation"
285 );
286 return;
287 }
288 };
289 let fresh_session_id = empty_to_none(&inst.session_id);
290 if rec.data.session_id == fresh_session_id {
291 return; // already current — skip the write.
292 }
293 rec.data.session_id = fresh_session_id;
294 rec.schema_version = rec.data.min_schema_version().max(rec.schema_version);
295 if let Err(e) = reg.upsert(&rec) {
296 tracing::warn!(
297 instance_id = %inst.id,
298 root_id = %root_id,
299 error = %e,
300 "registry: failed to propagate continuation session_id to chain root"
301 );
302 }
303 }
304
305 /// Walk `parent_instance_id` upward from `inst` to find the chain
306 /// root — the row with no parent. Bounded to guard against a
307 /// corrupted cyclic chain; real chains are a handful of user-driven
308 /// resumes deep.
309 ///
310 /// When a lookup misses locally, returns `current.parent_instance_id`
311 /// (the id we couldn't resolve), NOT `current.id`. Two cases produce a
312 /// local miss and both want that: (1) the id is a genuinely orphaned
313 /// continuation (its head was hard-deleted, no FK cascade) — the
314 /// registry has no file for it either, so the caller's `reg.get`
315 /// harmlessly no-ops; (2) the id is the chain root living in a
316 /// DIFFERENT channel's local SQLite — this store never has that row,
317 /// but the shared registry still does, under exactly this id. That
318 /// second case is the primary scenario this propagation exists for
319 /// (reopening an agent in another channel/build): `current.id` is a
320 /// continuation's own id, which never has a registry file (reagent P1
321 /// on PR #2755 — the original fallback made cross-channel propagation
322 /// always no-op, defeating the fix for its own target case).
323 fn find_chain_root_id(&self, inst: &AgentInstance) -> String {
324 let mut current = inst.clone();
325 for _ in 0..64 {
326 if current.parent_instance_id.is_empty() {
327 return current.id;
328 }
329 match self.instance_get(¤t.parent_instance_id) {
330 Ok(Some(parent)) => current = parent,
331 _ => return current.parent_instance_id.clone(),
332 }
333 }
334 current.id
335 }
336}