1use std::path::{Path, PathBuf};
32
33use clawft_types::config::KernelConfig;
34use clawft_types::runtime_paths::{
35 RootSource, RuntimePaths, legacy_chain_left_behind, legacy_migration_marker,
36 user_chain_checkpoint, user_runtime_root,
37};
38
39pub fn boot_runtime_paths(pinned_chain: Option<&Path>) -> RuntimePaths {
46 #[cfg(test)]
47 if let Some(dir) = pinned_chain.and_then(Path::parent) {
48 return RuntimePaths::at(dir);
49 }
50 let _ = pinned_chain;
51 RuntimePaths::resolve()
52}
53
54static NEW_CHAIN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
55
56static ADOPT_LEGACY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
57
58pub fn request_adopt_legacy_chain(on: bool) {
62 ADOPT_LEGACY.store(on, std::sync::atomic::Ordering::SeqCst);
63}
64
65pub fn request_new_chain(on: bool) {
69 NEW_CHAIN.store(on, std::sync::atomic::Ordering::SeqCst);
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ChainChoice {
75 pub checkpoint: PathBuf,
77 pub legacy_in_use: bool,
79 pub warning: Option<String>,
81 pub refusal: Option<String>,
83 pub transient: bool,
87}
88
89pub const LEGACY_ACTIVE_WINDOW: std::time::Duration = std::time::Duration::from_secs(120);
91
92pub(crate) fn lock_unaware_writer_age(checkpoint: &Path, now: std::time::SystemTime) -> Option<u64> {
99 if ChainLock::lock_path(checkpoint).exists() {
100 return None;
101 }
102 [checkpoint.to_path_buf(), checkpoint.with_extension("rvf")]
103 .iter()
104 .filter_map(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok())
105 .filter_map(|m| now.duration_since(m).ok())
106 .min()
107 .filter(|age| *age < LEGACY_ACTIVE_WINDOW)
108 .map(|age| age.as_secs())
109}
110
111fn has_chain(checkpoint: &Path) -> bool {
112 checkpoint.exists() || checkpoint.with_extension("rvf").exists()
113}
114
115fn legacy_adoption_refusal(
120 legacy: &Path,
121 adopt_legacy: bool,
122 now: std::time::SystemTime,
123) -> Option<(String, bool)> {
124 if !ChainLock::lock_path(legacy).exists() && !adopt_legacy {
125 return Some((format!(
126 "The legacy chain at {} has never been used by a lock-aware kernel. Stop every \
127 older weaver daemon (check `ps`/`weaver doctor daemon`) and then run \
128 `weaver kernel start --adopt-legacy-chain` once. Or use --new-chain.",
129 legacy.display()
130 ), false));
131 }
132 lock_unaware_writer_age(legacy, now).map(|age| {
134 (
135 format!(
136 "the legacy chain at {} looks in use by an older kernel (modified {age}s ago); \
137 stop it first or use --new-chain",
138 legacy.display()
139 ),
140 true,
141 )
142 })
143}
144
145fn migrated_or_adoption_refusal(
148 dir: &Path,
149 legacy: &Path,
150 adopt_legacy: bool,
151 now: std::time::SystemTime,
152) -> (Option<String>, bool) {
153 if let Some(m) = migrated_refusal(dir, adopt_legacy) {
154 return (Some(m), false);
155 }
156 match legacy_adoption_refusal(legacy, adopt_legacy, now) {
157 Some((m, t)) => (Some(m), t),
158 None => (None, false),
159 }
160}
161
162fn migrated_refusal(dir: &Path, adopt_legacy: bool) -> Option<String> {
166 let (marker, dest) = legacy_migration_marker(dir)?;
167 if adopt_legacy {
168 tracing::warn!(
169 marker = %marker.display(),
170 "--adopt-legacy-chain overrides a migration marker: this kernel appends to the \
171 legacy chain and forks history from the migrated user chain"
172 );
173 return None;
174 }
175 Some(format!(
176 "the legacy chain in {} was migrated to {} (see {}); booting on it would fork \
177 history. Use the migrated chain (`weaver kernel start --profile user`), isolate this run with WEFTOS_RUNTIME_DIR, or \
178 pass --adopt-legacy-chain to knowingly continue on the legacy copy",
179 dir.display(),
180 dest.as_deref().unwrap_or("~/.weftos/chain"),
181 marker.display()
182 ))
183}
184
185fn explicit_path_refusal(checkpoint: &Path, adopt_legacy: bool) -> Option<String> {
191 let dir = checkpoint.parent()?;
192 let (marker, dest) = legacy_migration_marker(dir)?;
193 if adopt_legacy {
194 tracing::warn!(
195 marker = %marker.display(),
196 "--adopt-legacy-chain overrides a migration marker for an explicit \
197 kernel.chain.checkpoint_path: this kernel forks history from the migrated chain"
198 );
199 return None;
200 }
201 Some(format!(
202 "kernel.chain.checkpoint_path ({}) points into {}, whose chain was migrated to {} \
203 (see {}); booting on it would fork history. Remove kernel.chain.checkpoint_path from \
204 the config (check ~/.clawft/config.json), point it at the migrated chain, or pass \
205 --adopt-legacy-chain to knowingly continue on the legacy copy",
206 checkpoint.display(),
207 dir.display(),
208 dest.as_deref().unwrap_or("~/.weftos/chain"),
209 marker.display()
210 ))
211}
212
213pub fn choose_default_chain(
220 paths: &RuntimePaths,
221 home: Option<&Path>,
222 new_chain: bool,
223 adopt_legacy: bool,
224 now: std::time::SystemTime,
225) -> ChainChoice {
226 let mut choice = choose_default_chain_inner(paths, home, new_chain, adopt_legacy, now);
227 let Some(home) = home else {
228 return choice;
229 };
230 let user = user_chain_checkpoint(home);
231 let legacy = home
232 .join(".clawft")
233 .join(clawft_types::runtime_paths::CHAIN_CHECKPOINT_FILE);
234 let on_legacy = !matches!(paths.source(), RootSource::User)
235 && choice.legacy_in_use
236 && choice.checkpoint == legacy;
237 if on_legacy && has_chain(&user) {
238 if adopt_legacy {
239 choice.warning = Some(format!(
240 "WARNING: appending to the legacy chain at {} although the chain was migrated to \
241 {} (--adopt-legacy-chain): the two chains will diverge",
242 legacy.display(),
243 user.display()
244 ));
245 } else {
246 choice.refusal = Some(format!(
247 "the legacy chain was migrated to {}; start the user daemon \
248 (`weaver kernel start --profile user`) or pass --adopt-legacy-chain to override",
249 user.display()
250 ));
251 }
252 }
253 choice
254}
255
256fn choose_default_chain_inner(
257 paths: &RuntimePaths,
258 home: Option<&Path>,
259 new_chain: bool,
260 adopt_legacy: bool,
261 now: std::time::SystemTime,
262) -> ChainChoice {
263 if matches!(paths.source(), RootSource::User) {
264 return choose_user_chain(paths, home, new_chain, adopt_legacy, now);
265 }
266 let resolved = paths.chain_checkpoint();
267 let plain = |checkpoint: PathBuf, warning: Option<String>, refusal: Option<String>| {
268 ChainChoice {
269 checkpoint,
270 legacy_in_use: false,
271 warning,
272 refusal,
273 transient: false,
274 }
275 };
276 if matches!(paths.source(), RootSource::LegacyHome) && has_chain(&resolved) {
282 let (refusal, transient) = if new_chain {
283 (
284 Some(format!(
285 "--new-chain cannot start a fresh chain at {} because the legacy chain \
286 lives there; start the kernel from a project, or set \
287 kernel.chain.checkpoint_path to a new location",
288 resolved.display()
289 )),
290 false,
291 )
292 } else {
293 migrated_or_adoption_refusal(paths.root(), &resolved, adopt_legacy, now)
294 };
295 return ChainChoice {
296 legacy_in_use: true,
297 transient,
298 ..plain(resolved, None, refusal)
299 };
300 }
301 let Some(legacy) = legacy_chain_left_behind(paths, home) else {
302 return plain(resolved, None, None);
303 };
304 if new_chain {
305 let warning = format!(
306 "starting a fresh chain at {} (--new-chain); the legacy chain at {} is untouched \
307 and this kernel will not append to it",
308 resolved.display(),
309 legacy.display()
310 );
311 return plain(resolved, Some(warning), None);
312 }
313 let legacy_dir = legacy.parent().unwrap_or(Path::new("."));
314 let (refusal, transient) =
315 migrated_or_adoption_refusal(legacy_dir, &legacy, adopt_legacy, now);
316 let warning = format!(
317 "no chain at {} but a legacy chain exists at {}; continuing on the legacy chain \
318 and its key so history is not forked (nothing was moved). Phase 1 \
319 `weaver migrate user-chain` will move it. Pass --new-chain to start a fresh \
320 chain at the resolved path instead, or set kernel.chain.checkpoint_path.",
321 resolved.display(),
322 legacy.display()
323 );
324 ChainChoice {
325 checkpoint: legacy,
326 legacy_in_use: true,
327 warning: Some(warning),
328 refusal,
329 transient,
330 }
331}
332
333fn choose_user_chain(
344 paths: &RuntimePaths,
345 home: Option<&Path>,
346 new_chain: bool,
347 adopt_legacy: bool,
348 now: std::time::SystemTime,
349) -> ChainChoice {
350 let standard = home.filter(|h| paths.root() == user_runtime_root(h));
351 let Some(home) = standard else {
352 return ChainChoice {
353 checkpoint: paths.chain_checkpoint(),
354 legacy_in_use: false,
355 warning: None,
356 refusal: None,
357 transient: false,
358 };
359 };
360 let user = user_chain_checkpoint(home);
361 let legacy = home
362 .join(".clawft")
363 .join(clawft_types::runtime_paths::CHAIN_CHECKPOINT_FILE);
364 if new_chain && has_chain(&user) {
365 return ChainChoice {
366 refusal: Some(format!(
367 "--new-chain would orphan the existing user chain at {}; move that directory \
368 aside first if a fresh chain is really intended",
369 user.display()
370 )),
371 checkpoint: user,
372 legacy_in_use: true,
373 warning: None,
374 transient: false,
375 };
376 }
377 if has_chain(&user) || new_chain || !has_chain(&legacy) {
378 let warning = (new_chain && has_chain(&legacy)).then(|| {
379 format!(
380 "starting a fresh user chain at {} (--new-chain); the legacy chain at {} is \
381 untouched and this kernel will not append to it",
382 user.display(),
383 legacy.display()
384 )
385 });
386 return ChainChoice {
387 checkpoint: user,
388 legacy_in_use: true,
389 warning,
390 refusal: None,
391 transient: false,
392 };
393 }
394 let (refusal, transient) =
395 migrated_or_adoption_refusal(home.join(".clawft").as_path(), &legacy, adopt_legacy, now);
396 ChainChoice {
397 refusal,
398 transient,
399 warning: Some(format!(
400 "no user chain at {} but a legacy chain exists at {}; continuing on the legacy \
401 chain and its key so history is not forked (nothing was moved). Run \
402 `weaver migrate user-chain` to move it, or pass --new-chain for a fresh user chain.",
403 user.display(),
404 legacy.display()
405 )),
406 checkpoint: legacy,
407 legacy_in_use: true,
408 }
409}
410
411pub fn pin_chain_storage(kernel_config: &mut KernelConfig) -> Option<PathBuf> {
416 pin_chain_storage_noted(kernel_config).path
417}
418
419#[derive(Debug, Default)]
421pub struct PinOutcome {
422 pub path: Option<PathBuf>,
424 pub warning: Option<String>,
426 pub refusal: Option<String>,
428 pub refusal_transient: bool,
430}
431
432pub fn pin_chain_storage_noted(kernel_config: &mut KernelConfig) -> PinOutcome {
434 let mut chain = kernel_config.chain.clone().unwrap_or_default();
435 if !chain.enabled {
436 return PinOutcome::default();
437 }
438 let mut warning = None;
439 let mut refusal = None;
440 let mut refusal_transient = false;
441 let mut legacy_in_use = false;
442 if chain.checkpoint_path.is_none() {
443 let (path, w, legacy, r, transient) = default_checkpoint_path(&chain);
444 chain.checkpoint_path = path;
445 warning = w;
446 refusal = r;
447 refusal_transient = transient;
448 legacy_in_use = legacy;
449 } else if let Some(p) = &chain.checkpoint_path {
450 refusal = explicit_path_refusal(
451 Path::new(p),
452 ADOPT_LEGACY.load(std::sync::atomic::Ordering::SeqCst),
453 );
454 }
455 if let (Some(anchor), Some(ckpt)) = (chain.external_anchor.as_mut(), &chain.checkpoint_path)
456 && anchor.ledger_path.is_none()
457 && (cfg!(test) || legacy_in_use)
458 {
459 let dir = PathBuf::from(ckpt);
461 let dir = dir.parent().unwrap_or(Path::new("."));
462 anchor.ledger_path = Some(
463 dir.join("chain")
464 .join("anchors.jsonl")
465 .to_string_lossy()
466 .into_owned(),
467 );
468 }
469 let pinned = chain.checkpoint_path.clone().map(PathBuf::from);
470 kernel_config.chain = Some(chain);
471 PinOutcome {
472 path: pinned,
473 warning,
474 refusal,
475 refusal_transient,
476 }
477}
478
479#[cfg(not(test))]
480fn default_checkpoint_path(
481 _chain: &clawft_types::config::ChainConfig,
482) -> (Option<String>, Option<String>, bool, Option<String>, bool) {
483 let paths = RuntimePaths::resolve();
484 let home = clawft_types::runtime_paths::home_dir();
485 let new_chain = NEW_CHAIN.load(std::sync::atomic::Ordering::SeqCst);
486 let adopt = ADOPT_LEGACY.load(std::sync::atomic::Ordering::SeqCst);
487 let choice = choose_default_chain(
488 &paths,
489 home.as_deref(),
490 new_chain,
491 adopt,
492 std::time::SystemTime::now(),
493 );
494 (
495 Some(choice.checkpoint.to_string_lossy().into_owned()),
496 choice.warning,
497 choice.legacy_in_use,
498 choice.refusal,
499 choice.transient,
500 )
501}
502
503#[cfg(test)]
505fn default_checkpoint_path(
506 _chain: &clawft_types::config::ChainConfig,
507) -> (Option<String>, Option<String>, bool, Option<String>, bool) {
508 use std::sync::atomic::{AtomicU64, Ordering};
509 static NEXT: AtomicU64 = AtomicU64::new(0);
510 let n = NEXT.fetch_add(1, Ordering::Relaxed);
511 let dir = std::env::temp_dir().join(format!(
512 "weftos-kernel-unit-chain-{}-{n}",
513 std::process::id()
514 ));
515 (
516 Some(
517 dir.join(clawft_types::runtime_paths::CHAIN_CHECKPOINT_FILE)
518 .to_string_lossy()
519 .into_owned(),
520 ),
521 None,
522 false,
523 None,
524 false,
525 )
526}
527
528#[derive(Debug)]
534pub struct ChainLock {
535 _file: std::fs::File,
536 path: PathBuf,
537}
538
539#[derive(Debug, Clone, PartialEq, Eq)]
541pub enum ChainLockError {
542 InUse(String),
544 Other(String),
546}
547
548impl ChainLock {
549 pub fn lock_path(checkpoint: &Path) -> PathBuf {
551 checkpoint.with_extension("lock")
552 }
553
554 pub fn acquire(checkpoint: &Path) -> Result<Self, String> {
561 Self::try_acquire(checkpoint).map_err(|e| match e {
562 ChainLockError::InUse(m) | ChainLockError::Other(m) => m,
563 })
564 }
565
566 pub fn try_acquire(checkpoint: &Path) -> Result<Self, ChainLockError> {
569 let path = Self::lock_path(checkpoint);
570 if let Some(dir) = path.parent() {
571 std::fs::create_dir_all(dir).map_err(|e| {
572 ChainLockError::Other(format!("cannot create chain dir {}: {e}", dir.display()))
573 })?;
574 }
575 let mut file = open_lock_file(&path).map_err(|e| {
576 ChainLockError::Other(format!("cannot open chain lock {}: {e}", path.display()))
577 })?;
578 match try_lock(&file) {
579 Ok(true) => {
580 record_pid(&mut file);
581 Ok(Self { _file: file, path })
582 }
583 Ok(false) => Err(ChainLockError::InUse(format!(
584 "chain {} is in use by another kernel (pid {}); refusing to share a chain. \
585 Give this kernel its own: start with --new-chain or set \
586 kernel.chain.checkpoint_path",
587 checkpoint.display(),
588 holder_pid(&path)
589 ))),
590 Err(e) => Err(ChainLockError::Other(format!("cannot lock {}: {e}", path.display()))),
591 }
592 }
593
594 pub fn probe(checkpoint: &Path) -> Result<(), String> {
597 let path = Self::lock_path(checkpoint);
598 if !path.exists() {
599 return Ok(());
600 }
601 let file = std::fs::OpenOptions::new()
602 .read(true)
603 .write(true)
604 .open(&path)
605 .map_err(|e| format!("cannot open chain lock {}: {e}", path.display()))?;
606 match try_lock(&file) {
607 Ok(true) => Ok(()),
608 Ok(false) => Err(format!(
609 "chain {} is in use by another kernel (pid {})",
610 checkpoint.display(),
611 holder_pid(&path)
612 )),
613 Err(e) => Err(format!("cannot probe {}: {e}", path.display())),
614 }
615 }
616
617 pub fn path(&self) -> &Path {
619 &self.path
620 }
621}
622
623fn open_lock_file(path: &Path) -> std::io::Result<std::fs::File> {
624 let mut o = std::fs::OpenOptions::new();
625 o.read(true).write(true).create(true).truncate(false);
626 #[cfg(windows)]
627 {
628 use std::os::windows::fs::OpenOptionsExt;
629 o.share_mode(0);
630 }
631 o.open(path)
632}
633
634#[cfg(unix)]
636fn try_lock(file: &std::fs::File) -> std::io::Result<bool> {
637 use std::os::fd::AsRawFd;
638 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
640 if rc == 0 {
641 return Ok(true);
642 }
643 let err = std::io::Error::last_os_error();
644 if err.kind() == std::io::ErrorKind::WouldBlock {
645 Ok(false)
646 } else {
647 Err(err)
648 }
649}
650
651#[cfg(not(unix))]
654fn try_lock(_file: &std::fs::File) -> std::io::Result<bool> {
655 Ok(true)
656}
657
658fn record_pid(file: &mut std::fs::File) {
659 use std::io::{Seek, Write};
660 let _ = file.set_len(0);
661 let _ = file.rewind();
662 let _ = write!(file, "{}", std::process::id());
663 let _ = file.flush();
664}
665
666fn holder_pid(lock: &Path) -> String {
667 std::fs::read_to_string(lock)
668 .ok()
669 .and_then(|s| s.trim().parse::<u32>().ok())
670 .map_or_else(|| "?".into(), |p| p.to_string())
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676 use clawft_types::config::ChainConfig;
677
678 fn far_future() -> std::time::SystemTime {
680 std::time::SystemTime::now() + std::time::Duration::from_secs(86_400)
681 }
682
683 fn legacy_fixture(t: &tempfile::TempDir) -> (PathBuf, PathBuf) {
685 let home = t.path().join("home");
686 std::fs::create_dir_all(home.join(".clawft")).unwrap();
687 std::fs::write(home.join(".clawft/chain.json"), "{}").unwrap();
688 let proj = t.path().join("proj");
689 std::fs::create_dir_all(proj.join(".weftos")).unwrap();
690 std::fs::write(proj.join(".weftos/project.toml"), "").unwrap();
691 (home, proj)
692 }
693
694 #[test]
695 fn legacy_present_and_project_missing_uses_legacy_chain() {
696 let t = tempfile::tempdir().unwrap();
697 let (home, proj) = legacy_fixture(&t);
698 let paths = RuntimePaths::resolve_with(None, Some(&proj), Some(&home));
699 let c = choose_default_chain(&paths, Some(&home), false, false, far_future());
700 assert!(c.legacy_in_use);
701 assert_eq!(c.checkpoint, home.join(".clawft/chain.json"));
702 let w = c.warning.expect("warns");
703 assert!(w.contains(".weftos/runtime/chain.json"), "{w}");
704 assert!(w.contains(".clawft/chain.json"), "{w}");
705 assert!(w.contains("weaver migrate user-chain"), "{w}");
706 assert!(w.contains("--new-chain"), "{w}");
707 }
708
709 #[test]
712 fn legacy_home_root_requires_explicit_first_adoption() {
713 let t = tempfile::tempdir().unwrap();
714 let (home, _proj) = legacy_fixture(&t);
715 let paths = RuntimePaths::resolve_with(None, Some(&home), Some(&home));
716 assert_eq!(paths.source(), &RootSource::LegacyHome);
717 let legacy = home.join(".clawft/chain.json");
718
719 let c = choose_default_chain(&paths, Some(&home), false, false, far_future());
720 assert_eq!(c.checkpoint, legacy);
721 let r = c.refusal.expect("refused without --adopt-legacy-chain");
722 assert!(r.contains("--adopt-legacy-chain"), "{r}");
723
724 let c = choose_default_chain(&paths, Some(&home), false, true, far_future());
725 assert!(c.refusal.is_none() && c.legacy_in_use);
726
727 std::fs::write(ChainLock::lock_path(&legacy), "").unwrap();
728 let c = choose_default_chain(&paths, Some(&home), false, false, far_future());
729 assert!(c.refusal.is_none(), "a lock-aware kernel already used it");
730 }
731
732 #[test]
733 fn only_the_age_window_refusal_is_transient() {
734 let t = tempfile::tempdir().unwrap();
735 let (home, _proj) = legacy_fixture(&t);
736 let paths = RuntimePaths::resolve_with(None, Some(&home), Some(&home));
737 let now = std::time::SystemTime::now();
738 let c = choose_default_chain(&paths, Some(&home), false, false, now);
740 assert!(c.refusal.is_some() && !c.transient);
741 let c = choose_default_chain(&paths, Some(&home), false, true, now);
743 assert!(c.refusal.expect("recent write").contains("looks in use"));
744 assert!(c.transient);
745 let c = choose_default_chain(&paths, Some(&home), false, true, far_future());
747 assert!(c.refusal.is_none() && !c.transient);
748 }
749
750 #[test]
751 fn legacy_home_root_refuses_new_chain_over_the_legacy_chain() {
752 let t = tempfile::tempdir().unwrap();
753 let (home, _proj) = legacy_fixture(&t);
754 let paths = RuntimePaths::resolve_with(None, Some(&home), Some(&home));
755 let c = choose_default_chain(&paths, Some(&home), true, false, far_future());
756 let r = c.refusal.expect("--new-chain would overwrite the legacy chain");
757 assert!(r.contains("checkpoint_path"), "{r}");
758 }
759
760 #[test]
761 fn legacy_home_root_without_a_chain_starts_fresh() {
762 let t = tempfile::tempdir().unwrap();
763 let home = t.path().join("home");
764 std::fs::create_dir_all(&home).unwrap();
765 let paths = RuntimePaths::resolve_with(None, Some(&home), Some(&home));
766 let c = choose_default_chain(&paths, Some(&home), false, false, far_future());
767 assert!(c.refusal.is_none() && !c.legacy_in_use);
768 }
769
770 #[test]
771 fn user_profile_prefers_the_user_chain_then_legacy_then_fresh() {
772 let t = tempfile::tempdir().unwrap();
773 let (home, _proj) = legacy_fixture(&t);
774 let paths = RuntimePaths::user_with(None, Some(&home));
775 let legacy = home.join(".clawft/chain.json");
776 let user = home.join(".weftos/chain/chain.json");
777
778 let c = choose_default_chain(&paths, Some(&home), false, false, far_future());
780 assert_eq!(c.checkpoint, legacy);
781 assert!(c.refusal.expect("guard").contains("--adopt-legacy-chain"));
782 let c = choose_default_chain(&paths, Some(&home), false, true, far_future());
783 assert!(c.refusal.is_none() && c.checkpoint == legacy);
784
785 let c = choose_default_chain(&paths, Some(&home), true, false, far_future());
787 assert_eq!(c.checkpoint, user);
788 assert!(c.refusal.is_none() && c.warning.unwrap().contains("--new-chain"));
789
790 std::fs::create_dir_all(user.parent().unwrap()).unwrap();
792 std::fs::write(&user, "{}").unwrap();
793 let c = choose_default_chain(&paths, Some(&home), false, false, far_future());
794 assert_eq!((c.checkpoint.clone(), c.refusal), (user.clone(), None));
795
796 let c = choose_default_chain(&paths, Some(&home), true, false, far_future());
798 assert!(c.refusal.expect("refused").contains("orphan"));
799 }
800
801 #[test]
802 fn default_daemon_is_refused_the_legacy_chain_once_a_user_chain_exists() {
803 let t = tempfile::tempdir().unwrap();
804 let (home, proj) = legacy_fixture(&t);
805 let user = home.join(".weftos/chain/chain.json");
806 std::fs::create_dir_all(user.parent().unwrap()).unwrap();
807 std::fs::write(&user, "{}").unwrap();
808 let legacy = home.join(".clawft/chain.json");
809 let pp = RuntimePaths::resolve_with(None, Some(&proj), Some(&home));
811 let c = choose_default_chain(&pp, Some(&home), false, false, far_future());
812 assert!(c.legacy_in_use && c.refusal.expect("refused").contains("--profile user"));
813 let paths = RuntimePaths::resolve_with(None, Some(&home), Some(&home));
815 let c = choose_default_chain(&paths, Some(&home), false, false, far_future());
816 let r = c.refusal.expect("refused");
817 assert!(
818 r.contains("--profile user") && r.contains("--adopt-legacy-chain"),
819 "{r}"
820 );
821 let c = choose_default_chain(&paths, Some(&home), false, true, far_future());
823 assert_eq!(c.checkpoint, legacy);
824 assert!(c.refusal.is_none());
825 assert!(c.warning.unwrap().contains("diverge"));
826 std::fs::create_dir_all(proj.join(".weftos/runtime")).unwrap();
828 std::fs::write(proj.join(".weftos/runtime/chain.json"), "{}").unwrap();
829 let paths = RuntimePaths::resolve_with(None, Some(&proj), Some(&home));
830 let c = choose_default_chain(&paths, Some(&home), false, false, far_future());
831 assert!(c.refusal.is_none() && !c.legacy_in_use);
832 }
833
834 #[test]
835 fn user_profile_with_no_chain_anywhere_starts_a_user_chain() {
836 let t = tempfile::tempdir().unwrap();
837 let home = t.path().join("home");
838 std::fs::create_dir_all(&home).unwrap();
839 let paths = RuntimePaths::user_with(None, Some(&home));
840 let c = choose_default_chain(&paths, Some(&home), false, false, far_future());
841 assert_eq!(c.checkpoint, home.join(".weftos/chain/chain.json"));
842 assert!(c.refusal.is_none() && c.warning.is_none());
843 }
844
845 #[test]
846 fn user_profile_under_an_isolated_root_never_reads_home_chains() {
847 let t = tempfile::tempdir().unwrap();
848 let (home, _proj) = legacy_fixture(&t);
849 let iso = t.path().join("iso");
850 let paths = RuntimePaths::user_with(iso.to_str(), Some(&home));
851 let c = choose_default_chain(&paths, Some(&home), false, false, far_future());
852 assert_eq!(c.checkpoint, iso.join("chain.json"));
853 assert!(c.refusal.is_none() && !c.legacy_in_use);
854 }
855
856 #[test]
857 fn new_chain_flag_starts_fresh_at_the_resolved_path() {
858 let t = tempfile::tempdir().unwrap();
859 let (home, proj) = legacy_fixture(&t);
860 let paths = RuntimePaths::resolve_with(None, Some(&proj), Some(&home));
861 let c = choose_default_chain(&paths, Some(&home), true, false, far_future());
862 assert!(!c.legacy_in_use);
863 assert_eq!(c.checkpoint, proj.join(".weftos/runtime/chain.json"));
864 assert!(c.warning.unwrap().contains("--new-chain"));
865 }
866
867 #[test]
868 fn fresh_chain_when_no_chain_exists_anywhere() {
869 let t = tempfile::tempdir().unwrap();
870 let home = t.path().join("home");
871 let proj = t.path().join("proj");
872 std::fs::create_dir_all(proj.join(".weftos")).unwrap();
873 std::fs::write(proj.join(".weftos/project.toml"), "").unwrap();
874 let paths = RuntimePaths::resolve_with(None, Some(&proj), Some(&home));
875 let c = choose_default_chain(&paths, Some(&home), false, false, far_future());
876 assert!(!c.legacy_in_use && c.warning.is_none());
877 assert_eq!(c.checkpoint, proj.join(".weftos/runtime/chain.json"));
878 }
879
880 #[test]
881 fn existing_project_chain_wins_and_isolated_runs_never_adopt_legacy() {
882 let t = tempfile::tempdir().unwrap();
883 let (home, proj) = legacy_fixture(&t);
884 std::fs::create_dir_all(proj.join(".weftos/runtime")).unwrap();
885 std::fs::write(proj.join(".weftos/runtime/chain.json"), "{}").unwrap();
886 let paths = RuntimePaths::resolve_with(None, Some(&proj), Some(&home));
887 let c = choose_default_chain(&paths, Some(&home), false, false, far_future());
888 assert!(!c.legacy_in_use);
889 let iso = RuntimePaths::resolve_with(Some("/x"), Some(&proj), Some(&home));
890 let c = choose_default_chain(&iso, Some(&home), false, false, far_future());
891 assert_eq!(c.checkpoint, PathBuf::from("/x/chain.json"));
892 assert!(!c.legacy_in_use);
893 }
894
895 #[test]
896 fn first_legacy_adoption_needs_the_flag_then_becomes_normal() {
897 let t = tempfile::tempdir().unwrap();
898 let (home, proj) = legacy_fixture(&t);
899 let paths = RuntimePaths::resolve_with(None, Some(&proj), Some(&home));
900 let quiet = far_future();
901
902 let c = choose_default_chain(&paths, Some(&home), false, false, quiet);
904 let r = c.refusal.expect("first adoption must be explicit");
905 assert!(r.contains("never been used by a lock-aware kernel"), "{r}");
906 assert!(r.contains("--adopt-legacy-chain"), "{r}");
907 assert!(r.contains("--new-chain"), "{r}");
908
909 let c = choose_default_chain(&paths, Some(&home), false, true, quiet);
911 assert!(c.refusal.is_none() && c.legacy_in_use);
912 drop(ChainLock::acquire(&c.checkpoint).unwrap());
913 assert!(home.join(".clawft/chain.lock").exists());
914
915 let c = choose_default_chain(&paths, Some(&home), false, false, quiet);
917 assert!(c.refusal.is_none() && c.legacy_in_use);
918
919 let fresh = tempfile::tempdir().unwrap();
921 let (home2, proj2) = legacy_fixture(&fresh);
922 let p2 = RuntimePaths::resolve_with(None, Some(&proj2), Some(&home2));
923 assert!(
924 choose_default_chain(&p2, Some(&home2), true, false, quiet)
925 .refusal
926 .is_none()
927 );
928 }
929
930 #[test]
931 fn flag_does_not_override_a_very_recent_write() {
932 let t = tempfile::tempdir().unwrap();
933 let (home, proj) = legacy_fixture(&t);
934 let paths = RuntimePaths::resolve_with(None, Some(&proj), Some(&home));
935 let now = std::time::SystemTime::now();
936
937 let r = choose_default_chain(&paths, Some(&home), false, true, now)
938 .refusal
939 .expect("recent write must be refused even with the flag");
940 assert!(r.contains("older kernel"), "{r}");
941 assert!(r.contains("--new-chain"), "{r}");
942
943 let later = now + LEGACY_ACTIVE_WINDOW + std::time::Duration::from_secs(5);
944 assert!(
945 choose_default_chain(&paths, Some(&home), false, true, later)
946 .refusal
947 .is_none()
948 );
949
950 std::fs::write(home.join(".clawft/chain.lock"), "1").unwrap();
952 assert!(
953 choose_default_chain(&paths, Some(&home), false, false, now)
954 .refusal
955 .is_none()
956 );
957 }
958
959 #[cfg(unix)]
960 #[test]
961 fn second_kernel_on_one_chain_is_refused_with_holder_pid() {
962 let t = tempfile::tempdir().unwrap();
963 let ckpt = t.path().join("chain.json");
964 let first = ChainLock::acquire(&ckpt).expect("first lock");
965 assert_eq!(first.path(), t.path().join("chain.lock"));
966 let err = ChainLock::acquire(&ckpt).expect_err("second must fail");
967 assert!(
968 err.contains(&format!("pid {}", std::process::id())),
969 "{err}"
970 );
971 assert!(err.contains("--new-chain"), "{err}");
972 assert!(err.contains("kernel.chain.checkpoint_path"), "{err}");
973 assert!(matches!(ChainLock::try_acquire(&ckpt), Err(ChainLockError::InUse(_))));
975 let blocked = t.path().join("file");
976 std::fs::write(&blocked, "x").unwrap();
977 assert!(matches!(
978 ChainLock::try_acquire(&blocked.join("sub/chain.json")),
979 Err(ChainLockError::Other(_))
980 ));
981 drop(first);
982 ChainLock::acquire(&ckpt).expect("free after drop");
983 }
984
985 #[test]
986 fn explicit_path_is_kept() {
987 let mut k = KernelConfig {
988 chain: Some(ChainConfig {
989 checkpoint_path: Some("/data/c.json".into()),
990 ..ChainConfig::default()
991 }),
992 ..KernelConfig::default()
993 };
994 assert_eq!(
995 pin_chain_storage(&mut k),
996 Some(PathBuf::from("/data/c.json"))
997 );
998 }
999
1000 #[test]
1001 fn disabled_chain_is_not_pinned() {
1002 let mut k = KernelConfig {
1003 chain: Some(ChainConfig {
1004 enabled: false,
1005 ..ChainConfig::default()
1006 }),
1007 ..KernelConfig::default()
1008 };
1009 assert_eq!(pin_chain_storage(&mut k), None);
1010 assert!(k.chain.unwrap().checkpoint_path.is_none());
1011 }
1012
1013 #[test]
1014 fn unit_test_default_is_isolated_and_pinned() {
1015 let mut k = KernelConfig::default();
1016 let p = pin_chain_storage(&mut k).expect("pinned");
1017 assert!(p.starts_with(std::env::temp_dir()), "{}", p.display());
1018 if let Some(home) = std::env::var_os("HOME") {
1019 assert!(!p.starts_with(PathBuf::from(home).join(".clawft")));
1020 }
1021 assert_eq!(
1022 k.chain.unwrap().checkpoint_path.as_deref(),
1023 Some(p.to_string_lossy().as_ref())
1024 );
1025 }
1026
1027 #[test]
1028 fn explicit_checkpoint_path_into_a_migrated_dir_is_refused() {
1029 let t = tempfile::tempdir().unwrap();
1030 let legacy = t.path().join(".clawft");
1031 std::fs::create_dir_all(&legacy).unwrap();
1032 std::fs::write(legacy.join("chain.json"), "{}").unwrap();
1033 std::fs::write(
1034 legacy.join(clawft_types::runtime_paths::LEGACY_MIGRATED_MARKER),
1035 "migrated-to: /u/.weftos/chain\n",
1036 )
1037 .unwrap();
1038 let ckpt = legacy.join("chain.json");
1039
1040 let mut cfg = KernelConfig::default();
1041 let chain = ChainConfig {
1042 checkpoint_path: Some(ckpt.to_string_lossy().into_owned()),
1043 ..ChainConfig::default()
1044 };
1045 cfg.chain = Some(chain);
1046 let out = pin_chain_storage_noted(&mut cfg);
1047 let msg = out.refusal.expect("refused");
1048 assert!(msg.contains("kernel.chain.checkpoint_path"), "{msg}");
1049 assert!(msg.contains("--adopt-legacy-chain"), "{msg}");
1050
1051 assert!(explicit_path_refusal(&ckpt, true).is_none());
1053 let other = t.path().join("elsewhere").join("chain.json");
1054 assert!(explicit_path_refusal(&other, false).is_none());
1055 }
1056}