Skip to main content

agentmux_srv/migrations/
m0034_identity_history_backfill.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! One-time sweep for identity conversation history stranded before
5//! `ensure_history_link` (`SPEC_AGENT_IDENTITY_HISTORY_PERSISTENCE_PROTOCOL_2026_08_16.md`,
6//! shipped in `0007c6b64051e8` / PR #2605) existed to keep it linked into
7//! the always-global `shared/identities/<id>/<provider>/<subdir>/` tree.
8//!
9//! ## What went wrong
10//!
11//! `ensure_history_link` already does the hard part — migrate a real,
12//! pre-existing directory's contents into the global target (never
13//! clobbering an existing entry) and replace it with a junction/symlink —
14//! but it only ever runs at `auth.start` and at ordinary agent spawn
15//! (`identity_auth_dirs::link_history_if_isolated`, called from
16//! `identity/resolver/inject.rs`). A channel that stopped being spawned
17//! into before the fix landed — retired, superseded by a newer dev
18//! branch, or simply abandoned — never spawns again, so that call site
19//! never re-runs for it. Its real `identities/<bundle>/<provider>/<subdir>/`
20//! directory is left exactly as it was: live conversation history that
21//! `ClaudeHistoryAdapter`'s per-channel fallback scan can still find and
22//! read (so it isn't *lost*), but that will never be linked into the
23//! global tree, and will never benefit from a fix to the linking logic
24//! itself, because nothing ever calls it again for that channel.
25//!
26//! Confirmed on a live install: two channels
27//! (`local-agentx-fix-lan-discovery-tx-fc5809-ec08a24c`,
28//! `local-main-b28b7a-0e5d07ad`) born on 2026-08-16 — the exact day the
29//! fix shipped — carry four such real, unlinked, non-empty identity
30//! history directories, last written 2026-09-03 and never touched since.
31//!
32//! ## What this does
33//!
34//! Walks every `identities/` directory found up to 2 levels under
35//! `<home>/channels/*` and `<home>/dev/*` — covering `channels/<slug>/`,
36//! `dev/<branch>/`, and the clone-scoped `dev/<branch>/<clone_id>/` shape
37//! (see [`identities_roots`], and `ClaudeHistoryAdapter`'s identical
38//! bounded traversal, which this mirrors on purpose). For every
39//! `identities/<bundle_id>/<provider.auth_dir_name>/<history_subdir>` path
40//! that exists on disk, calls the live `ensure_history_link` to merge it
41//! into `<home>/shared/identities/<bundle_id>/<provider.auth_dir_name>/<history_subdir>`
42//! and replace it with a link — the exact same operation a spawn would
43//! have performed, just run once, retroactively, for every channel that
44//! will never spawn again.
45//!
46//! Deliberately calls the LIVE `ensure_history_link` rather than freezing
47//! a copy (contrast the guidance in this module's own doc comment about
48//! migrations that produce DB rows): that guidance protects rows whose
49//! *shape* is meaningful and time-bound. This migration produces no rows
50//! at all — it performs a filesystem merge-and-link operation that
51//! `ensure_history_link` already defines as idempotent and safe
52//! ("never deletes or overwrites data"). Freezing a copy would mean a
53//! future safety fix to that function (it has already had one real bug
54//! fixed under it, per PR #2605's review) never applies to this
55//! migration, on any machine that runs it after that fix ships — the
56//! opposite of what freezing is meant to protect against.
57//!
58//! Scoped `Global`: it only ever writes under `<home>/shared/`, and must
59//! run at most once regardless of which channel happens to run it first.
60//!
61//! Never invents new structure: only acts where a real
62//! `identities/<bundle>/<provider>/<subdir>` path already exists on disk.
63//! Does not create a link (or the target directory) for a bundle/provider
64//! combination that was never present, which `ensure_history_link` would
65//! otherwise happily do (it unconditionally `create_dir_all`s the
66//! target). Best-effort per entry, matching `link_history_if_isolated`'s
67//! own philosophy: one bundle's I/O error (e.g. a genuine name collision
68//! `ensure_history_link` refuses to resolve automatically) is logged and
69//! skipped, never aborts the sweep for every other bundle.
70
71use agentmux_common::ensure_history_link;
72
73use crate::backend::providers::all_providers;
74
75use super::{Migration, MigrationContext, MigrationError, MigrationScope};
76
77pub struct M0034IdentityHistoryBackfill;
78
79impl Migration for M0034IdentityHistoryBackfill {
80    fn id(&self) -> &'static str {
81        "0034_identity_history_backfill"
82    }
83    fn scope(&self) -> MigrationScope {
84        MigrationScope::Global
85    }
86    fn description(&self) -> &'static str {
87        "Link orphaned pre-fix per-channel identity history into the global shared tree"
88    }
89
90    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
91        let mut linked = 0usize;
92        let mut failed = 0usize;
93
94        for identities_root in identities_roots(&ctx.home) {
95            let Ok(bundle_entries) = std::fs::read_dir(&identities_root) else {
96                continue;
97            };
98
99            for bundle_entry in bundle_entries.flatten() {
100                if !bundle_entry.path().is_dir() {
101                    continue;
102                }
103                let bundle_id = bundle_entry.file_name().to_string_lossy().into_owned();
104                if !is_safe_path_segment(&bundle_id) {
105                    tracing::warn!(
106                        bundle_id,
107                        identities_root = %identities_root.display(),
108                        "identity_history_backfill: skipping unsafe bundle id"
109                    );
110                    continue;
111                }
112
113                for provider in all_providers() {
114                    let Some(subdir) = provider.history_native_subdir else {
115                        continue;
116                    };
117                    let link_path = identities_root
118                        .join(&bundle_id)
119                        .join(provider.auth_dir_name)
120                        .join(subdir);
121                    // Only act where something already exists — never
122                    // invent a bundle/provider pairing that wasn't here.
123                    if std::fs::symlink_metadata(&link_path).is_err() {
124                        continue;
125                    }
126                    let target_dir = ctx
127                        .home
128                        .join("shared")
129                        .join("identities")
130                        .join(&bundle_id)
131                        .join(provider.auth_dir_name)
132                        .join(subdir);
133
134                    match ensure_history_link(&link_path, &target_dir) {
135                        Ok(()) => linked += 1,
136                        Err(e) => {
137                            failed += 1;
138                            tracing::warn!(
139                                bundle_id,
140                                provider_id = provider.id,
141                                link_path = %link_path.display(),
142                                error = %e,
143                                "identity_history_backfill: failed to link a stranded \
144                                 identity history directory; leaving it in place"
145                            );
146                        }
147                    }
148                }
149            }
150        }
151
152        tracing::info!(linked, failed, "identity_history_backfill: complete");
153        Ok(())
154    }
155}
156
157/// Every `identities/` directory up to 2 levels under `<home>/channels/`
158/// and `<home>/dev/`. Deliberately mirrors
159/// `ClaudeHistoryAdapter::scan_isolated_identities_under`'s bounded
160/// traversal (`crates/srv/src/backend/history/claude_adapter.rs`) rather
161/// than assuming a fixed one-level shape: `DataPaths::resolve` places a
162/// dev instance at `dev/<branch>/` when there is no clone id, but at
163/// `dev/<branch>/<clone_id>/` whenever one is derived — which
164/// `agentmux_common::runtime_mode::derive_clone_id` does for essentially
165/// every real dev checkout (anywhere a `.git`/`Cargo.toml`/`Taskfile.yml`
166/// is found up the tree), not an edge case. An earlier revision of this
167/// function only checked one level under `dev/`, which silently found
168/// nothing for that layout and would have marked the migration applied
169/// without ever backfilling clone-scoped dev instances — caught in review
170/// (muxreview + Codex, independently, on the same commit) before merge.
171/// Depth 2 covers every known layout: `channels/<slug>/identities/`,
172/// `dev/<branch>/identities/`, and `dev/<branch>/<clone_id>/identities/`.
173/// Kept as its own copy rather than calling the adapter's function
174/// directly: that one is Claude-specific (hardcodes `claude/projects`)
175/// and private to its module; this migration needs the `identities/`
176/// directory itself, for every provider.
177fn identities_roots(home: &std::path::Path) -> Vec<std::path::PathBuf> {
178    let mut out = Vec::new();
179    for base in ["channels", "dev"] {
180        collect_identities_roots(&home.join(base), &mut out, 2);
181    }
182    out
183}
184
185fn collect_identities_roots(
186    root: &std::path::Path,
187    out: &mut Vec<std::path::PathBuf>,
188    max_depth: u8,
189) {
190    let Ok(entries) = std::fs::read_dir(root) else {
191        return;
192    };
193    for entry in entries.flatten() {
194        let path = entry.path();
195        if !path.is_dir() {
196            continue;
197        }
198        let identities = path.join("identities");
199        if identities.is_dir() {
200            out.push(identities);
201        }
202        if max_depth > 1 {
203            collect_identities_roots(&path, out, max_depth - 1);
204        }
205    }
206}
207
208/// Mirrors `agentmux_common::data_paths`'s private `sanitize_path_segment`
209/// as a boolean check only (that function isn't `pub`, and this call site
210/// only ever needs to know whether a name discovered on disk is safe to
211/// re-join into a path — never the sanitized value itself, since the
212/// original `bundle_entry` path is already known-safe on this filesystem
213/// and is what actually gets used). Kept as its own small, stable copy
214/// rather than exporting the original: this predicate is simple path
215/// hygiene, not the isolation-sensitive logic `ensure_history_link` and
216/// the migration framework's own "don't freeze what might change" note
217/// are about.
218fn is_safe_path_segment(s: &str) -> bool {
219    if s != s.trim() || s.is_empty() || s == "." || s == ".." {
220        return false;
221    }
222    !s.chars().any(|c| {
223        matches!(
224            c,
225            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0'
226        )
227    })
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn ctx_at(home: std::path::PathBuf) -> MigrationContext {
235        MigrationContext {
236            shared_store_path: home.join("shared").join("store.db"),
237            channel_store_path: home.join("unused-objects.db"),
238            data_dir: home.clone(),
239            home,
240        }
241    }
242
243    /// The real-world case: a real, populated `projects/` directory left
244    /// behind under an old channel's isolated identity dir must end up
245    /// merged into the global tree, with the old location replaced by a
246    /// link to it — the same outcome a spawn into that channel would
247    /// have produced, had one ever happened again.
248    #[test]
249    fn links_a_stranded_real_history_directory_into_the_shared_tree() {
250        let tmp = tempfile::tempdir().unwrap();
251        let home = tmp.path().to_path_buf();
252
253        let stranded = home
254            .join("channels")
255            .join("local-old-retired-channel")
256            .join("identities")
257            .join("bundle-1")
258            .join("claude")
259            .join("projects")
260            .join("some-project");
261        std::fs::create_dir_all(&stranded).unwrap();
262        std::fs::write(stranded.join("session-1.jsonl"), "orphaned history").unwrap();
263
264        M0034IdentityHistoryBackfill
265            .up(&ctx_at(home.clone()))
266            .unwrap();
267
268        let global_file = home
269            .join("shared")
270            .join("identities")
271            .join("bundle-1")
272            .join("claude")
273            .join("projects")
274            .join("some-project")
275            .join("session-1.jsonl");
276        assert_eq!(
277            std::fs::read_to_string(&global_file).unwrap(),
278            "orphaned history",
279            "content must be reachable from the global location after the sweep"
280        );
281
282        // The precise on-disk link mechanism (junction vs symlink) is
283        // `ensure_history_link`'s own, already-tested responsibility;
284        // this migration only needs to prove it called that primitive
285        // for the right path. `is_symlink()` alone isn't a portable
286        // enough check for a Windows junction specifically (see
287        // `ensure_history_link`'s own doc comment on why it uses the
288        // `junction` crate rather than `symlink_metadata` there), so
289        // assert the content-reachability property instead, which is
290        // what a caller actually depends on.
291        let old_location_file = home
292            .join("channels")
293            .join("local-old-retired-channel")
294            .join("identities")
295            .join("bundle-1")
296            .join("claude")
297            .join("projects")
298            .join("some-project")
299            .join("session-1.jsonl");
300        assert_eq!(
301            std::fs::read_to_string(&old_location_file).unwrap(),
302            "orphaned history",
303            "old location must still resolve to the same content via the new link"
304        );
305    }
306
307    /// Also sweeps `dev/<branch>/identities/...` — the other base
308    /// `DataPaths::instance_dir` resolves to, not just `channels/*`.
309    #[test]
310    fn sweeps_dev_branch_roots_too() {
311        let tmp = tempfile::tempdir().unwrap();
312        let home = tmp.path().to_path_buf();
313
314        let stranded = home
315            .join("dev")
316            .join("some-branch")
317            .join("identities")
318            .join("bundle-1")
319            .join("codex")
320            .join("sessions");
321        std::fs::create_dir_all(&stranded).unwrap();
322        std::fs::write(stranded.join("s.json"), "dev branch history").unwrap();
323
324        M0034IdentityHistoryBackfill
325            .up(&ctx_at(home.clone()))
326            .unwrap();
327
328        let global_file = home
329            .join("shared")
330            .join("identities")
331            .join("bundle-1")
332            .join("codex")
333            .join("sessions")
334            .join("s.json");
335        assert_eq!(
336            std::fs::read_to_string(&global_file).unwrap(),
337            "dev branch history"
338        );
339    }
340
341    /// The gap caught in review (muxreview + Codex, independently, same
342    /// commit): a dev build with a clone id nests one level deeper —
343    /// `dev/<branch>/<clone_id>/identities/...`, per
344    /// `DataPaths::resolve`'s `RuntimeMode::Dev` branch — which
345    /// `sweeps_dev_branch_roots_too` above does not exercise. This is the
346    /// common case, not an edge case: `derive_clone_id` succeeds (and so
347    /// this layout applies) for essentially every real `task dev`
348    /// checkout. Must be found and linked exactly like the shallow form.
349    #[test]
350    fn sweeps_clone_scoped_dev_roots_too() {
351        let tmp = tempfile::tempdir().unwrap();
352        let home = tmp.path().to_path_buf();
353
354        let stranded = home
355            .join("dev")
356            .join("some-branch")
357            .join("a1b2c3d4")
358            .join("identities")
359            .join("bundle-1")
360            .join("gemini")
361            .join("history");
362        std::fs::create_dir_all(&stranded).unwrap();
363        std::fs::write(stranded.join("h.json"), "clone-scoped dev history").unwrap();
364
365        M0034IdentityHistoryBackfill
366            .up(&ctx_at(home.clone()))
367            .unwrap();
368
369        let global_file = home
370            .join("shared")
371            .join("identities")
372            .join("bundle-1")
373            .join("gemini")
374            .join("history")
375            .join("h.json");
376        assert_eq!(
377            std::fs::read_to_string(&global_file).unwrap(),
378            "clone-scoped dev history"
379        );
380    }
381
382    /// A channel whose identity history is ALREADY correctly linked
383    /// (the common case on any install that's spawned since the 08-16
384    /// fix) must be left alone — `ensure_history_link` itself already
385    /// treats this as a no-op, and this migration must not error or
386    /// duplicate anything on top of it.
387    #[test]
388    fn is_a_no_op_for_an_already_linked_history_directory() {
389        let tmp = tempfile::tempdir().unwrap();
390        let home = tmp.path().to_path_buf();
391
392        let target = home
393            .join("shared")
394            .join("identities")
395            .join("bundle-1")
396            .join("claude")
397            .join("projects");
398        std::fs::create_dir_all(&target).unwrap();
399        std::fs::write(target.join("session-1.jsonl"), "already global").unwrap();
400
401        let link_path = home
402            .join("channels")
403            .join("local-current")
404            .join("identities")
405            .join("bundle-1")
406            .join("claude")
407            .join("projects");
408        std::fs::create_dir_all(link_path.parent().unwrap()).unwrap();
409        ensure_history_link(&link_path, &target).unwrap();
410
411        M0034IdentityHistoryBackfill
412            .up(&ctx_at(home.clone()))
413            .unwrap();
414
415        assert_eq!(
416            std::fs::read_to_string(target.join("session-1.jsonl")).unwrap(),
417            "already global"
418        );
419    }
420
421    /// Never invents a link (or the target dir) for a bundle/provider
422    /// pairing that was never present on disk — only real, pre-existing
423    /// paths get touched.
424    #[test]
425    fn never_creates_a_link_for_a_provider_that_was_never_present() {
426        let tmp = tempfile::tempdir().unwrap();
427        let home = tmp.path().to_path_buf();
428        std::fs::create_dir_all(
429            home.join("channels")
430                .join("local-current")
431                .join("identities")
432                .join("bundle-1"),
433        )
434        .unwrap();
435
436        M0034IdentityHistoryBackfill
437            .up(&ctx_at(home.clone()))
438            .unwrap();
439
440        assert!(
441            !home
442                .join("shared")
443                .join("identities")
444                .join("bundle-1")
445                .join("claude")
446                .exists(),
447            "no claude history dir should be materialized when none ever existed"
448        );
449    }
450
451    /// Running it twice must not error or double-migrate — an operator
452    /// re-running migrations is routine.
453    #[test]
454    fn is_idempotent_on_a_second_run() {
455        let tmp = tempfile::tempdir().unwrap();
456        let home = tmp.path().to_path_buf();
457
458        let stranded = home
459            .join("channels")
460            .join("local-old")
461            .join("identities")
462            .join("bundle-1")
463            .join("claude")
464            .join("projects");
465        std::fs::create_dir_all(&stranded).unwrap();
466        std::fs::write(stranded.join("session-1.jsonl"), "orphaned history").unwrap();
467
468        let ctx = ctx_at(home.clone());
469        M0034IdentityHistoryBackfill.up(&ctx).unwrap();
470        M0034IdentityHistoryBackfill.up(&ctx).unwrap();
471
472        let global_file = home
473            .join("shared")
474            .join("identities")
475            .join("bundle-1")
476            .join("claude")
477            .join("projects")
478            .join("session-1.jsonl");
479        assert_eq!(
480            std::fs::read_to_string(&global_file).unwrap(),
481            "orphaned history"
482        );
483    }
484
485    /// A fresh install with no `channels/` or `dev/` dir at all must not
486    /// error — the migration runner calls every migration on every
487    /// install, including a brand new one.
488    #[test]
489    fn is_a_no_op_when_no_channel_roots_exist_yet() {
490        let tmp = tempfile::tempdir().unwrap();
491        M0034IdentityHistoryBackfill
492            .up(&ctx_at(tmp.path().to_path_buf()))
493            .unwrap();
494    }
495
496    #[test]
497    fn rejects_unsafe_bundle_ids_found_on_disk() {
498        assert!(is_safe_path_segment("bundle-1"));
499        assert!(!is_safe_path_segment(".."));
500        assert!(!is_safe_path_segment(""));
501        assert!(!is_safe_path_segment("a/b"));
502        assert!(!is_safe_path_segment(" padded "));
503    }
504}