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}