1use std::path::{Path, PathBuf};
22
23use crate::backend::session_backfill::session_file_path;
24
25const MARKER_SUFFIX: &str = ".agentmux-relocated";
26const TMP_INFIX: &str = ".agentmux-tmp-";
27const TAIL_CHECK_BYTES: u64 = 256 * 1024;
30
31pub(crate) fn source_file(config_dir: &str, cwd: &str, sid: &str) -> Option<PathBuf> {
36 let local = session_file_path(config_dir, cwd, sid)?;
37 if local.is_file() {
38 return Some(local);
39 }
40 let global = global_twin(config_dir)?;
41 let path = session_file_path(&global.to_string_lossy(), cwd, sid)?;
42 path.is_file().then_some(path)
43}
44
45fn global_twin(config_dir: &str) -> Option<PathBuf> {
48 let parts: Vec<String> =
49 Path::new(config_dir).components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect();
50 let at = parts.iter().rposition(|p| p == "identities")?;
51 let rest = parts.get(at + 1..).filter(|r| !r.is_empty())?;
52 let mut twin = crate::registry::resolve_global_shared_root()?.join("identities");
53 for p in rest {
54 twin.push(p);
55 }
56 (twin != Path::new(config_dir)).then_some(twin)
57}
58
59pub(crate) fn is_resumable_file(path: &Path) -> bool {
62 last_record(path, false).is_ok_and(|(_, record)| parses(&record))
63}
64
65pub(crate) fn can_relocate(config_dir: &str, cwd: &str, sid: &str) -> bool {
70 source_file(config_dir, cwd, sid)
71 .and_then(|p| last_record(&p, true).ok())
72 .is_some_and(|(_, record)| parses(&record))
73}
74
75fn last_record(path: &Path, complete_only: bool) -> std::io::Result<(u64, Vec<u8>)> {
81 use std::io::{Read, Seek, SeekFrom};
82 let mut f = std::fs::File::open(path)?;
83 let len = f.metadata()?.len();
84 let mut window = TAIL_CHECK_BYTES;
85 loop {
86 let start = len.saturating_sub(window);
87 f.seek(SeekFrom::Start(start))?;
88 let mut tail = Vec::new();
89 f.read_to_end(&mut tail)?;
90 let newline_before = |at: usize| tail[..at].iter().rposition(|b| *b == b'\n');
91 let end = match (complete_only, newline_before(tail.len())) {
92 (false, _) => Some(tail.len()),
93 (true, Some(at)) => Some(at + 1),
94 (true, None) => (start == 0).then_some(tail.len()),
95 };
96 if let Some(end) = end {
97 let body = tail[..end].iter().rposition(|b| !b.is_ascii_whitespace()).map_or(0, |at| at + 1);
98 match newline_before(body) {
99 Some(at) => return Ok((start + end as u64, tail[at + 1..body].to_vec())),
100 None if start == 0 => return Ok((end as u64, tail[..body].to_vec())),
101 None => {}
102 }
103 }
104 window = window.saturating_mul(4);
105 }
106}
107
108fn parses(record: &[u8]) -> bool {
111 serde_json::from_slice::<serde_json::Value>(record).is_ok()
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
120pub(crate) struct Relocated {
121 pub copy: PathBuf,
122 marker: PathBuf,
123}
124
125pub(crate) fn relocate(src: &Path, dest_config_dir: &str, cwd: &str, sid: &str, owner_uid: &str) -> Result<Relocated, String> {
129 let dest = session_file_path(dest_config_dir, cwd, sid).ok_or("no destination path")?;
130 if dest.exists() {
131 return Err(format!("{} already exists", dest.display()));
132 }
133 let dir = dest.parent().ok_or("destination has no parent")?;
134 std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
135 let token = uuid::Uuid::new_v4().simple().to_string();
139 let tmp = dir.join(format!("{sid}.jsonl{TMP_INFIX}{owner_uid}.{token}"));
140 let bytes = match std::fs::copy(src, &tmp).and_then(|_| trim_to_complete_records(&tmp)) {
141 Ok(b) => b,
142 Err(e) => {
143 let _ = std::fs::remove_file(&tmp);
144 return Err(format!("copy {}: {e}", src.display()));
145 }
146 };
147 if !is_resumable_file(&tmp) {
152 let _ = std::fs::remove_file(&tmp);
153 return Err(format!("{} has no complete record to fork from", src.display()));
154 }
155 let marker = marker_for(&dest, &token);
156 let record = serde_json::json!({ "owner_uid": owner_uid, "source": src.to_string_lossy(), "bytes": bytes });
157 let placed = std::fs::write(&marker, record.to_string())
158 .map_err(|e| format!("marker {}: {e}", marker.display()))
159 .and_then(|()| place(&tmp, &dest).map_err(|e| format!("place {}: {e}", dest.display())));
160 let _ = std::fs::remove_file(&tmp);
161 if let Err(e) = placed {
162 let _ = std::fs::remove_file(&marker);
163 return Err(e);
164 }
165 Ok(Relocated { copy: dest, marker })
166}
167
168fn trim_to_complete_records(path: &Path) -> std::io::Result<u64> {
173 let (keep, _) = last_record(path, true)?;
174 let f = std::fs::OpenOptions::new().write(true).open(path)?;
175 if keep < f.metadata()?.len() {
176 f.set_len(keep)?;
177 }
178 Ok(keep)
179}
180
181fn place(tmp: &Path, dest: &Path) -> std::io::Result<()> {
186 std::fs::hard_link(tmp, dest)
187}
188
189pub(crate) fn remove(r: &Relocated) -> bool {
193 if !unmark(r) {
194 return false;
195 }
196 remove_file(&r.copy);
197 true
198}
199
200pub(crate) fn unmark(r: &Relocated) -> bool {
204 match std::fs::remove_file(&r.marker) {
205 Ok(()) => true,
206 Err(e) => {
207 if e.kind() != std::io::ErrorKind::NotFound {
208 tracing::warn!(target: "continuity", path = %r.marker.display(), error = %e, "relocated copy: unmark failed");
209 }
210 false
211 }
212 }
213}
214
215fn remove_file(p: &Path) {
216 if let Err(e) = std::fs::remove_file(p) {
217 if e.kind() != std::io::ErrorKind::NotFound {
218 tracing::warn!(target: "continuity", path = %p.display(), error = %e, "relocated copy: remove failed");
219 }
220 }
221}
222
223pub(crate) fn sweep(config_dir: &str, cwd: &str, owner_uid: &str) -> usize {
228 let Some(dir) = session_file_path(config_dir, cwd, "x").and_then(|p| p.parent().map(Path::to_path_buf)) else {
229 return 0;
230 };
231 let Ok(entries) = std::fs::read_dir(&dir) else { return 0 };
232 let tmp_tag = format!("{TMP_INFIX}{owner_uid}");
235 let mut removed = 0;
236 for entry in entries.flatten() {
237 let name = entry.file_name().to_string_lossy().into_owned();
238 if name.ends_with(&tmp_tag) || name.contains(&format!("{tmp_tag}.")) {
239 let _ = std::fs::remove_file(entry.path());
240 } else if let Some(copy_name) = name.strip_suffix(MARKER_SUFFIX).map(copy_name_of) {
241 let owner = std::fs::read_to_string(entry.path())
242 .ok()
243 .and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
244 .and_then(|v| v.get("owner_uid").and_then(|o| o.as_str()).map(str::to_string));
245 if owner.as_deref() == Some(owner_uid) {
246 remove(&Relocated { copy: dir.join(copy_name), marker: entry.path() });
247 removed += 1;
248 }
249 }
250 }
251 removed
252}
253
254fn marker_for(copy: &Path, token: &str) -> PathBuf {
257 let mut name = copy.file_name().map(|n| n.to_os_string()).unwrap_or_default();
258 name.push(format!(".{token}{MARKER_SUFFIX}"));
259 copy.with_file_name(name)
260}
261
262fn copy_name_of(stem: &str) -> &str {
265 if stem.ends_with(".jsonl") {
266 stem
267 } else {
268 stem.rsplit_once('.').map_or(stem, |(copy, _token)| copy)
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 const CWD: &str = "/agents/agenta";
277
278 fn cfg() -> tempfile::TempDir {
279 tempfile::tempdir().unwrap()
280 }
281
282 fn write_session(config_dir: &Path, sid: &str, body: &str) -> PathBuf {
283 let p = session_file_path(&config_dir.to_string_lossy(), CWD, sid).unwrap();
284 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
285 std::fs::write(&p, body).unwrap();
286 p
287 }
288
289 fn s(p: &Path) -> String {
290 p.to_string_lossy().into_owned()
291 }
292
293 #[test]
294 fn a_complete_session_is_resumable_and_a_cut_or_empty_one_is_not() {
295 let d = cfg();
296 let good = write_session(d.path(), "good", "{\"a\":1}\n{\"b\":2}\n");
297 let cut = write_session(d.path(), "cut", "{\"a\":1}\n{\"b\":");
298 let empty = write_session(d.path(), "empty", "");
299 assert!(is_resumable_file(&good));
300 assert!(!is_resumable_file(&cut), "a file cut mid-record");
301 assert!(!is_resumable_file(&empty));
302 assert!(!is_resumable_file(&d.path().join("missing.jsonl")));
303 }
304
305 #[test]
308 fn a_record_larger_than_the_tail_window_is_read_whole() {
309 let (from, to) = (cfg(), cfg());
310 let big = format!("{{\"result\":\"{}\"}}
311", "x".repeat(TAIL_CHECK_BYTES as usize * 2));
312 let src = write_session(from.path(), "big", &format!("{{\"turn\":1}}
313{big}{{\"tur"));
314 assert!(!is_resumable_file(&src));
315 assert!(can_relocate(&s(from.path()), CWD, "big"));
316 let r = relocate(&src, &s(to.path()), CWD, "big", "uid-1").unwrap();
317 assert_eq!(std::fs::read_to_string(&r.copy).unwrap(), format!("{{\"turn\":1}}
318{big}"));
319 assert!(is_resumable_file(&r.copy));
320 }
321
322 #[test]
326 fn a_source_mid_record_can_be_relocated_but_not_resumed_in_place() {
327 let d = cfg();
328 let live = write_session(d.path(), "live", "{\"turn\":1}
329{\"tur");
330 write_session(d.path(), "partial", "{\"tur");
331 assert!(!is_resumable_file(&live));
332 assert!(can_relocate(&s(d.path()), CWD, "live"));
333 assert!(!can_relocate(&s(d.path()), CWD, "partial"), "no complete record");
334 assert!(!can_relocate(&s(d.path()), CWD, "missing"));
335 }
336
337 #[test]
340 fn a_copy_of_a_live_session_ends_on_its_last_complete_record() {
341 let (from, to) = (cfg(), cfg());
342 let src = write_session(from.path(), "live", "{\"turn\":1}
343{\"turn\":2}
344{\"tur");
345 let r = relocate(&src, &s(to.path()), CWD, "live", "uid-1").unwrap();
346 assert_eq!(std::fs::read_to_string(&r.copy).unwrap(), "{\"turn\":1}
347{\"turn\":2}
348");
349 assert!(std::fs::read_to_string(&src).unwrap().ends_with("{\"tur"), "the source is untouched");
350
351 let (from, to) = (cfg(), cfg());
352 let src = write_session(from.path(), "partial", "{\"tur");
353 assert!(relocate(&src, &s(to.path()), CWD, "partial", "uid-1").is_err());
354 let dest = session_file_path(&s(to.path()), CWD, "partial").unwrap();
355 assert!(!dest.exists(), "nothing placed");
356 assert_eq!(std::fs::read_dir(dest.parent().unwrap()).map(|d| d.count()).unwrap_or(0), 0, "no temp copy left");
357 }
358
359 #[test]
360 fn relocation_copies_marks_and_never_touches_the_original() {
361 let (from, to) = (cfg(), cfg());
362 let src = write_session(from.path(), "head", "{\"turn\":1}\n");
363 let r = relocate(&src, &s(to.path()), CWD, "head", "uid-1").unwrap();
364 assert_eq!(std::fs::read_to_string(&r.copy).unwrap(), "{\"turn\":1}\n");
365 assert_eq!(r.copy, session_file_path(&s(to.path()), CWD, "head").unwrap());
366 assert!(r.marker.is_file(), "a copy is never unmarked");
367 assert_eq!(std::fs::read_to_string(&src).unwrap(), "{\"turn\":1}\n");
368 let leftovers: Vec<_> = std::fs::read_dir(r.copy.parent().unwrap())
369 .unwrap()
370 .flatten()
371 .filter(|e| e.file_name().to_string_lossy().contains(TMP_INFIX))
372 .collect();
373 assert!(leftovers.is_empty(), "no half-made copy remains");
374 }
375
376 #[test]
377 fn an_existing_session_is_never_overwritten() {
378 let (from, to) = (cfg(), cfg());
379 let src = write_session(from.path(), "head", "{\"from\":\"elsewhere\"}\n");
380 let existing = write_session(to.path(), "head", "{\"mine\":true}\n");
381 assert!(relocate(&src, &s(to.path()), CWD, "head", "uid-1").is_err());
382 assert_eq!(std::fs::read_to_string(&existing).unwrap(), "{\"mine\":true}\n");
383 let markers = std::fs::read_dir(existing.parent().unwrap())
384 .unwrap()
385 .flatten()
386 .filter(|e| e.file_name().to_string_lossy().ends_with(MARKER_SUFFIX))
387 .count();
388 assert_eq!(markers, 0);
389 }
390
391 #[test]
392 fn remove_takes_the_copy_and_its_marker() {
393 let (from, to) = (cfg(), cfg());
394 let src = write_session(from.path(), "head", "{}\n");
395 let r = relocate(&src, &s(to.path()), CWD, "head", "uid-1").unwrap();
396 assert!(remove(&r));
397 assert!(!r.copy.exists() && !r.marker.exists());
398 assert!(!remove(&r), "gone already: quiet, and not this relocation's any more");
399 }
400
401 #[test]
404 fn a_stale_handle_never_touches_a_later_relocation_at_the_same_path() {
405 let (from, to) = (cfg(), cfg());
406 let src = write_session(from.path(), "head", "{}\n");
407 let old = relocate(&src, &s(to.path()), CWD, "head", "uid-1").unwrap();
408 assert_eq!(sweep(&s(to.path()), CWD, "uid-1"), 1);
409 let new = relocate(&src, &s(to.path()), CWD, "head", "uid-1").unwrap();
410 assert_eq!(old.copy, new.copy);
411
412 assert!(!unmark(&old));
413 assert!(!remove(&old));
414 assert!(new.copy.exists() && new.marker.exists(), "the replacement's copy is still its own");
415 assert!(unmark(&new));
416 }
417
418 #[test]
421 fn placing_never_replaces_a_copy_already_there() {
422 let d = cfg();
423 let dest = write_session(d.path(), "head", "{\"winner\":true}\n");
424 let tmp = dest.with_file_name("head.jsonl.agentmux-tmp-uid-2");
425 std::fs::write(&tmp, "{\"loser\":true}\n").unwrap();
426 assert_eq!(place(&tmp, &dest).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists);
427 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "{\"winner\":true}\n");
428 }
429
430 #[test]
433 fn a_relocation_leaves_no_writable_alias_of_its_copy() {
434 let (from, to) = (cfg(), cfg());
435 let src = write_session(from.path(), "head", "{\"turn\":1}\n");
436 let r = relocate(&src, &s(to.path()), CWD, "head", "uid-1").unwrap();
437 let names: Vec<String> = std::fs::read_dir(r.copy.parent().unwrap())
438 .unwrap()
439 .flatten()
440 .map(|e| e.file_name().to_string_lossy().into_owned())
441 .collect();
442 assert!(!names.iter().any(|n| n.contains(TMP_INFIX)), "no temp name left: {names:?}");
443 }
444
445 #[test]
446 fn half_made_copies_are_swept_with_or_without_a_token_but_only_this_agents() {
447 let to = cfg();
448 let dir = write_session(to.path(), "ordinary", "{}\n").parent().unwrap().to_path_buf();
449 let tokened = dir.join(format!("head.jsonl{TMP_INFIX}uid-1.abc123"));
450 let legacy = dir.join(format!("other.jsonl{TMP_INFIX}uid-1"));
451 let longer_uid = dir.join(format!("head.jsonl{TMP_INFIX}uid-10.abc123"));
452 for p in [&tokened, &legacy, &longer_uid] {
453 std::fs::write(p, "{}").unwrap();
454 }
455 sweep(&s(to.path()), CWD, "uid-1");
456 assert!(!tokened.exists() && !legacy.exists());
457 assert!(longer_uid.exists(), "uid-10's temp file is not uid-1's");
458 }
459
460 #[test]
461 fn a_marker_from_before_tokens_is_still_swept() {
462 let to = cfg();
463 let copy = write_session(to.path(), "head", "{}\n");
464 let legacy = copy.with_file_name(format!("head.jsonl{MARKER_SUFFIX}"));
465 std::fs::write(&legacy, r#"{"owner_uid":"uid-1"}"#).unwrap();
466 assert_eq!(sweep(&s(to.path()), CWD, "uid-1"), 1);
467 assert!(!copy.exists() && !legacy.exists());
468 }
469
470 #[test]
471 fn an_unmarked_copy_is_a_live_session_no_sweep_touches() {
472 let (from, to) = (cfg(), cfg());
473 let src = write_session(from.path(), "head", "{}\n");
474 let r = relocate(&src, &s(to.path()), CWD, "head", "uid-1").unwrap();
475 assert!(unmark(&r));
476 assert_eq!(sweep(&s(to.path()), CWD, "uid-1"), 0);
477 assert!(r.copy.exists());
478 }
479
480 #[test]
481 fn a_sweep_removes_only_this_agents_copies() {
482 let (from, to) = (cfg(), cfg());
483 let src = write_session(from.path(), "head", "{}\n");
484 let mine = relocate(&src, &s(to.path()), CWD, "head", "uid-1").unwrap();
485 let src2 = write_session(from.path(), "other", "{}\n");
486 let theirs = relocate(&src2, &s(to.path()), CWD, "other", "uid-2").unwrap();
487 let ordinary = write_session(to.path(), "ordinary", "{}\n");
488 let half_made = mine.copy.parent().unwrap().join(format!("x.jsonl{TMP_INFIX}uid-1"));
489 std::fs::write(&half_made, "{}").unwrap();
490
491 assert_eq!(sweep(&s(to.path()), CWD, "uid-1"), 1);
492 assert!(!mine.copy.exists() && !mine.marker.exists());
493 assert!(!half_made.exists());
494 assert!(theirs.copy.exists() && theirs.marker.exists(), "another agent's copy stays");
495 assert!(ordinary.exists(), "an ordinary session stays");
496 assert_eq!(sweep(&s(to.path()), CWD, "uid-1"), 0);
497 assert_eq!(sweep("/no/such/dir", CWD, "uid-1"), 0);
498 }
499
500 #[test]
501 fn the_source_is_found_under_the_login_s_global_history_when_the_channel_dir_is_gone() {
502 let _g = crate::test_support::ISOLATED_AUTH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
504 let home = cfg();
505 std::env::set_var("AGENTMUX_HOME_OVERRIDE", home.path());
506 let gone_channel_dir = home.path().join("channels").join("old-build").join("identities").join("acct-1").join("claude");
507 let global = home.path().join("shared").join("identities").join("acct-1").join("claude");
508 let path = write_session(&global, "head", "{}\n");
509 let found = source_file(&s(&gone_channel_dir), CWD, "head");
510 let missing = source_file(&s(&gone_channel_dir), CWD, "missing");
511 std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
512 assert_eq!(found, Some(path));
513 assert_eq!(missing, None);
514 }
515}