Skip to main content

clawft_kernel/
chain_storage.rs

1//! Pin the chain storage location once at boot, and guard it with a lock.
2//!
3//! The kernel reads the chain checkpoint path in several places: boot
4//! (signing key, RVF restore, tree checkpoint), the daemon's `chain.verify`
5//! RPC, and shutdown persistence. [`pin_chain_storage`] resolves the path a
6//! single time and writes it into the kernel config, so all of them agree
7//! even if the environment changes after boot.
8//!
9//! Resolution, highest first:
10//!
11//! 1. an explicit `kernel.chain.checkpoint_path`;
12//! 2. `chain.json` under the runtime root
13//!    ([`clawft_types::runtime_paths::RuntimePaths`]: `$WEFTOS_RUNTIME_DIR`,
14//!    else the project's `.weftos/runtime`, else legacy `~/.clawft`);
15//! 3. a project root with no chain yet, while `~/.clawft/chain.*` exists
16//!    (and no `--new-chain`): keep using the legacy chain and key. Booting a
17//!    fresh genesis here would silently fork the operator's history, so the
18//!    legacy chain stays in use (WARN) until `weaver migrate user-chain`
19//!    (ADR-103 Phase 1) moves it.
20//!
21//! Once the legacy chain
22//! carries a `MIGRATED-TO-WEFTOS.txt` marker, a boot that would still land on
23//! it is refused (it would fork history) unless `WEFTOS_RUNTIME_DIR` isolates
24//! it or `--adopt-legacy-chain` is passed (with a WARN).
25//!
26//! Whichever chain is in use is guarded by [`ChainLock`] (`chain.lock` beside
27//! it) for the kernel's lifetime, so two kernels can never append to one
28//! chain. In this crate's own unit tests the default is a fresh temp dir per
29//! boot, so `cargo test` never touches the operator chain.
30
31use 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
39/// The runtime paths this boot uses for every non-chain runtime file
40/// (cluster peers, apps, revoked hosts).
41///
42/// Production: the one shared resolver. Unit tests: the directory holding
43/// the pinned per-boot temp chain, so nothing lands in the operator's
44/// runtime dir.
45pub 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
58/// Allow the next boot to adopt the legacy `~/.clawft` chain for the first
59/// time (`weaver kernel start --adopt-legacy-chain`). Needed only while no
60/// `chain.lock` exists beside it, i.e. no lock-aware kernel has used it yet.
61pub fn request_adopt_legacy_chain(on: bool) {
62    ADOPT_LEGACY.store(on, std::sync::atomic::Ordering::SeqCst);
63}
64
65/// Ask the next boot to start a fresh chain at the resolved path instead of
66/// adopting the legacy `~/.clawft` chain (`weaver kernel start --new-chain`).
67/// Process-wide on purpose: it is an operator flag, not a config-file field.
68pub fn request_new_chain(on: bool) {
69    NEW_CHAIN.store(on, std::sync::atomic::Ordering::SeqCst);
70}
71
72/// Which chain a default-path boot should use.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ChainChoice {
75    /// Checkpoint path to pin (RVF, key and tree derive from it).
76    pub checkpoint: PathBuf,
77    /// True when this is the legacy `~/.clawft` chain, not the resolved one.
78    pub legacy_in_use: bool,
79    /// WARN text for the operator, when the choice needs explaining.
80    pub warning: Option<String>,
81    /// Set when adopting the legacy chain looks unsafe: boot must refuse.
82    pub refusal: Option<String>,
83    /// `refusal` clears by itself within [`LEGACY_ACTIVE_WINDOW`] (an older
84    /// kernel's recent write): a plain boot error, so a service manager's
85    /// retry is the right response, not the permanent exit 78.
86    pub transient: bool,
87}
88
89/// A legacy chain modified within this window may still have a live writer.
90pub const LEGACY_ACTIVE_WINDOW: std::time::Duration = std::time::Duration::from_secs(120);
91
92/// Seconds since the legacy chain (json or rvf) was last modified, when that
93/// is inside [`LEGACY_ACTIVE_WINDOW`] and no `chain.lock` exists beside it.
94///
95/// Kernels that know about the lock create `chain.lock` the first time they
96/// use a chain, so its absence means the last writer was an older,
97/// lock-unaware build that may still be running.
98pub(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
115/// Refusal for adopting a legacy chain that only lock-unaware kernels have
116/// written. With no `chain.lock` beside it an older daemon may still be
117/// running, so the first adoption must be explicit; even then a very recent
118/// write is refused.
119fn 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    // The age window passes on its own, so this one is transient.
133    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
145/// Migration-marker refusal for `dir`, else the legacy adoption refusal for
146/// `legacy`; the flag is [`ChainChoice::transient`].
147fn 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
162/// Fork hazard: the legacy chain in `dir` was migrated (marker beside it), and
163/// this boot would still append to it. `--adopt-legacy-chain` overrides, with
164/// a loud WARN.
165fn 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
185/// Explicit `kernel.chain.checkpoint_path` guard (Phase 1 review S5): an
186/// explicit path skips every default-chain rule, so a config that pins the
187/// legacy chain would keep appending to it after `weaver migrate user-chain`.
188/// Refuse when the path's directory carries the migration marker, unless
189/// `--adopt-legacy-chain` was passed (loud WARN, as for the default path).
190fn 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
213/// Choose the default chain for `paths` (see module docs, rule 2 and 3).
214///
215/// Once a user chain exists at `~/.weftos/chain` (the result of
216/// `weaver migrate user-chain`), a kernel that would still adopt the legacy
217/// `~/.clawft` chain is refused: it would append to the old copy and fork
218/// history. `--adopt-legacy-chain` overrides, with a loud warning.
219pub 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    // Rooted at ~/.clawft itself (any non-project cwd, e.g. $HOME): the
277    // resolved chain IS the legacy chain, so the first-adoption guard applies
278    // (Phase 0 review R1), plus the migration marker. `--new-chain` cannot
279    // start a fresh chain in place of it: its first checkpoint would
280    // overwrite history.
281    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
333/// Chain choice for the user daemon (`--profile user`, ADR-103 Phase 1).
334///
335/// At the standard root (`~/.weftos/run`) the chain is, in order: the user
336/// chain `~/.weftos/chain` when one exists (what `weaver migrate user-chain`
337/// produces); else the legacy `~/.clawft` chain under the same first-adoption
338/// guard as Phase 0 (never a silent fresh genesis that would fork history);
339/// else a fresh user chain. Under an isolated root (`WEFTOS_RUNTIME_DIR`)
340/// the chain is that root's own and the operator's chains are never read.
341/// `legacy_in_use` is true whenever the chain is not at `paths`' own root,
342/// which keeps the anchor ledger beside the chain actually in use.
343fn 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
411/// Resolve the chain checkpoint path and pin it into `kernel_config.chain`.
412///
413/// Returns the pinned checkpoint path, or `None` when the chain is disabled
414/// or no location can be resolved (no home dir, no runtime dir).
415pub fn pin_chain_storage(kernel_config: &mut KernelConfig) -> Option<PathBuf> {
416    pin_chain_storage_noted(kernel_config).path
417}
418
419/// What [`pin_chain_storage_noted`] decided.
420#[derive(Debug, Default)]
421pub struct PinOutcome {
422    /// Pinned checkpoint path (`None` when the chain is disabled).
423    pub path: Option<PathBuf>,
424    /// WARN text explaining a non-obvious choice.
425    pub warning: Option<String>,
426    /// Boot must refuse with this message.
427    pub refusal: Option<String>,
428    /// The refusal clears on its own (see [`ChainChoice::transient`]).
429    pub refusal_transient: bool,
430}
431
432/// [`pin_chain_storage`] plus the warning and refusal for the choice.
433pub 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        // Keep the anchor ledger beside the chain in use.
460        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/// Unit-test default: a fresh temp dir per boot, never `~/.clawft`.
504#[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/// Exclusive advisory lock on `chain.lock` beside the chain in use.
529///
530/// Held for the kernel's lifetime (the OS drops it on any exit, including a
531/// crash). A second kernel on the same chain is refused with the holder's
532/// PID instead of appending to the chain concurrently.
533#[derive(Debug)]
534pub struct ChainLock {
535    _file: std::fs::File,
536    path: PathBuf,
537}
538
539/// Why [`ChainLock::try_acquire`] failed.
540#[derive(Debug, Clone, PartialEq, Eq)]
541pub enum ChainLockError {
542    /// Another kernel holds the lock.
543    InUse(String),
544    /// Filesystem or locking failure.
545    Other(String),
546}
547
548impl ChainLock {
549    /// Lock file path for a chain checkpoint (`chain.json` -> `chain.lock`).
550    pub fn lock_path(checkpoint: &Path) -> PathBuf {
551        checkpoint.with_extension("lock")
552    }
553
554    /// Take the lock for the chain at `checkpoint`.
555    ///
556    /// # Errors
557    ///
558    /// The message names the holder PID and the ways out (`--new-chain`,
559    /// `kernel.chain.checkpoint_path`).
560    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    /// [`acquire`](Self::acquire), telling "another kernel holds it" (a
567    /// refusal no retry fixes) apart from I/O failures.
568    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    /// Check that `checkpoint`'s chain is not locked, without creating or
595    /// writing the lock file. `Ok` when no lock file exists or it is free.
596    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    /// The lock file.
618    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/// `Ok(true)` when locked, `Ok(false)` when another holder has it.
635#[cfg(unix)]
636fn try_lock(file: &std::fs::File) -> std::io::Result<bool> {
637    use std::os::fd::AsRawFd;
638    // SAFETY: flock on a valid, open file descriptor owned by `file`.
639    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/// Windows holds the lock through the no-sharing open; other targets have
652/// no advisory lock.
653#[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    /// A "now" far past any fixture mtime, so the recency guard stays quiet.
679    fn far_future() -> std::time::SystemTime {
680        std::time::SystemTime::now() + std::time::Duration::from_secs(86_400)
681    }
682
683    /// A fake home holding a legacy chain, and a project without one.
684    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    /// Phase 0 review R1: a kernel rooted at ~/.clawft itself (any
710    /// non-project cwd, e.g. $HOME) gets the same first-adoption guard.
711    #[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        // Never used by a lock-aware kernel: needs an operator, permanent.
739        let c = choose_default_chain(&paths, Some(&home), false, false, now);
740        assert!(c.refusal.is_some() && !c.transient);
741        // Adopted explicitly but written moments ago: clears by itself.
742        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        // Same chain, long idle: no refusal at all.
746        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        // Legacy only: legacy chain, guarded like Phase 0.
779        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        // --new-chain: a fresh user chain, legacy untouched.
786        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        // A user chain wins over the legacy one.
791        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        // --new-chain now would orphan it: refused.
797        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        // A project with no chain of its own would fall back to legacy: refused.
810        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        // A legacy-rooted kernel would fall back to the legacy chain: refused.
814        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        // Override is kept, with a loud warning.
822        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        // A project that owns its own chain is not affected.
827        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        // No chain.lock and no flag: refused, with the operator recipe.
903        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        // With the flag: adopted; booting creates chain.lock.
910        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        // Next boot without the flag: adopted normally.
916        let c = choose_default_chain(&paths, Some(&home), false, false, quiet);
917        assert!(c.refusal.is_none() && c.legacy_in_use);
918
919        // --new-chain never needs the flag.
920        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        // Once a lock exists, a quick restart is fine.
951        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        // A held lock is the refusal class; an unusable path is not.
974        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        // The override passes, and an unmarked directory is never refused.
1052        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}