lean-ctx-engine 3.12.2

LeanCTX | OSS library: the open-source runtime for the Context Gateway for AI agents behind the lean-ctx CLI, MCP server and proxy integrations.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
// SPDX-License-Identifier: Apache-2.0

use chrono::Utc;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};

use super::heuristics::{normalize_loaded_session, session_matches_project_root};
use super::paths::sessions_dir;
use super::save_outcome::SaveOutcome;
use super::state::extract_session_facts;
#[allow(clippy::wildcard_imports)]
use super::types::*;

/// Keep the startup warm set deliberately small: cache warming is an optional
/// optimisation and must never turn process startup into a session-store scan.
const PROJECT_HISTORY_LIMIT: usize = 8;

/// How long a save waits for the per-session lock before reporting failure.
/// Generous enough that an ordinary concurrent save wins it, short enough that
/// a holder which has stopped making progress cannot hold a caller forever.
#[cfg(not(test))]
const SAVE_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
/// The wedged-holder test waits this out under the global test env lock.
#[cfg(test)]
const SAVE_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionScopeError {
    ProjectRootMismatch,
}

#[derive(Debug, Default, Deserialize, Serialize)]
struct ProjectSessionIndex {
    version: u8,
    project_root: String,
    /// Oldest to newest; duplicates are removed before appending on every save.
    session_ids: Vec<String>,
    /// Set only by a full-store scan that found no session for this root, written
    /// under the index lock. It makes "no session" a cached answer: without it
    /// every process (each hook call loads config, which resolves the project
    /// root) re-parsed the whole session store for a project without sessions.
    /// Any save for the root appends an id, which ends the empty state.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    verified_empty: bool,
}

fn normalized_safe_project_root(project_root: &str) -> Option<String> {
    let path = std::path::Path::new(project_root);
    if project_root.trim().is_empty() || crate::core::pathutil::is_broad_or_unsafe_root(path) {
        return None;
    }
    Some(
        crate::core::pathutil::safe_canonicalize_or_self(path)
            .to_string_lossy()
            .to_string(),
    )
}

/// Normalize a receiving project root without accepting an implicit CWD.
///
/// The legacy loader intentionally accepts relative roots for compatibility;
/// session replacement must not use that heuristic as an authority because a
/// relative value can resolve to a different process working directory.
fn normalized_strict_project_root(project_root: &str) -> Option<String> {
    let path = std::path::Path::new(project_root);
    if project_root.trim().is_empty()
        || !path.is_absolute()
        || crate::core::pathutil::is_broad_or_unsafe_root(path)
    {
        return None;
    }

    let normalized = crate::core::pathutil::safe_canonicalize_or_self(path);
    if !normalized.is_absolute() || crate::core::pathutil::is_broad_or_unsafe_root(&normalized) {
        return None;
    }
    Some(normalized.to_string_lossy().to_string())
}

fn project_index_path(dir: &std::path::Path, project_root: &str) -> std::path::PathBuf {
    let key = blake3::hash(project_root.as_bytes()).to_hex();
    dir.join("project-index").join(format!("{key}.json"))
}

fn read_project_index(dir: &std::path::Path, project_root: &str) -> Option<ProjectSessionIndex> {
    std::fs::read_to_string(project_index_path(dir, project_root))
        .ok()
        .and_then(|json| serde_json::from_str::<ProjectSessionIndex>(&json).ok())
        .filter(|index| index.version == 1 && index.project_root == project_root)
}

fn project_head_from_bytes(
    id: &str,
    project_root: &str,
    bytes: &[u8],
) -> Result<(String, String), String> {
    let json =
        std::str::from_utf8(bytes).map_err(|_| "corrupt project session head".to_string())?;
    let session = SessionState::from_storage_json(json)
        .map(normalize_loaded_session)
        .map_err(|_| "corrupt project session head".to_string())?;
    if session.id != id {
        return Err("corrupt project session head id".to_string());
    }
    if !session_matches_project_root(&session, std::path::Path::new(project_root)) {
        return Err("project session head root mismatch".to_string());
    }
    let digest = Sha256::digest(bytes);
    Ok((session.id, format!("sha256:{}", hex::encode(digest))))
}

/// Resolve the canonical project head without changing the index. Callers hold
/// the project lock so a save cannot move the index between selection and read.
fn read_project_head_locked(
    dir: &std::path::Path,
    project_root: &str,
) -> Result<Option<(String, String)>, String> {
    if project_index_path(dir, project_root).exists()
        && read_project_index(dir, project_root).is_none()
    {
        return Err("corrupt project session index".to_string());
    }
    if let Some(index) = read_project_index(dir, project_root)
        && let Some(id) = index.session_ids.last()
    {
        validate_session_id(id).map_err(|_| "invalid project session index head".to_string())?;
        let path = dir.join(format!("{id}.json"));
        match std::fs::read(path) {
            Ok(bytes) => {
                let json = std::str::from_utf8(&bytes)
                    .map_err(|_| "corrupt project session head".to_string())?;
                let session = SessionState::from_storage_json(json)
                    .map(normalize_loaded_session)
                    .map_err(|_| "corrupt project session head".to_string())?;
                if session.id.as_str() != id.as_str() {
                    return Err("corrupt project session head id".to_string());
                }
                let Some(session_root) = session.project_root.as_deref() else {
                    return Err("unsafe project session head root".to_string());
                };
                if normalized_safe_project_root(session_root).is_none() {
                    return Err("unsafe project session head root".to_string());
                }
                if session_matches_project_root(&session, std::path::Path::new(project_root)) {
                    let digest = Sha256::digest(&bytes);
                    return Ok(Some((
                        session.id,
                        format!("sha256:{}", hex::encode(digest)),
                    )));
                }
                // A safe but stale index entry follows the existing loader's
                // repair semantics; the scan below remains read-only for CAS.
            }
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
            Err(error) => return Err(format!("read project session head: {error}")),
        }
    }

    let entries = match std::fs::read_dir(dir) {
        Ok(entries) => entries,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(error) => return Err(format!("scan project sessions: {error}")),
    };
    let mut matches = Vec::new();
    for entry in entries {
        let entry = entry.map_err(|error| format!("scan project sessions: {error}"))?;
        let path = entry.path();
        if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
            continue;
        }
        let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
            continue;
        };
        if id == "latest" || id.starts_with('.') {
            continue;
        }
        let bytes = match std::fs::read(&path) {
            Ok(bytes) => bytes,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
            Err(error) => return Err(format!("read project session: {error}")),
        };
        let json = std::str::from_utf8(&bytes)
            .map_err(|_| "unreadable session during project recovery".to_string())?;
        let session = SessionState::from_storage_json(json)
            .map(normalize_loaded_session)
            .map_err(|_| "unreadable session during project recovery".to_string())?;
        if session.id != id {
            continue;
        }
        if session_matches_project_root(&session, std::path::Path::new(project_root)) {
            matches.push((session.updated_at, session.id, bytes));
        }
    }
    matches.sort_by_key(|(updated_at, _, _)| *updated_at);
    matches
        .pop()
        .map(|(_, id, bytes)| project_head_from_bytes(&id, project_root, &bytes))
        .transpose()
}

fn write_project_index(
    index_path: &std::path::Path,
    index: &ProjectSessionIndex,
) -> Result<(), String> {
    let json = serde_json::to_string(index).map_err(|e| format!("serialize project index: {e}"))?;
    let tmp = index_path.with_extension(format!("{}.tmp", std::process::id()));
    std::fs::write(&tmp, json).map_err(|e| format!("write project index: {e}"))?;
    restrict_file_permissions(&tmp);
    std::fs::rename(tmp, index_path).map_err(|e| format!("commit project index: {e}"))
}

fn with_project_index_lock<T>(
    dir: &std::path::Path,
    project_root: &str,
    operation: impl FnOnce(&std::path::Path) -> Result<T, String>,
) -> Result<T, String> {
    use fs2::FileExt;
    use std::time::Duration;

    const LOCK_TIMEOUT: Duration = Duration::from_millis(200);

    let index_path = project_index_path(dir, project_root);
    let index_dir = index_path.parent().ok_or("project index has no parent")?;
    std::fs::create_dir_all(index_dir).map_err(|e| format!("create project index: {e}"))?;
    let lock = std::fs::OpenOptions::new()
        .create(true)
        .truncate(false)
        .write(true)
        .open(index_path.with_extension("lock"))
        .map_err(|e| format!("project index lock: {e}"))?;
    crate::core::file_lock::acquire_exclusive_timeout(&lock, LOCK_TIMEOUT)
        .map_err(|e| format!("project index lock: {e}"))?;
    let result = operation(&index_path);
    let _ = FileExt::unlock(&lock);
    result
}

/// Update one project's bounded warm-history index while its project lock is
/// already held. The index remains an acceleration structure.
fn update_project_index_locked(
    dir: &std::path::Path,
    project_root: &str,
    index_path: &std::path::Path,
    id: &str,
) -> Result<(), String> {
    let mut index = read_project_index(dir, project_root).unwrap_or_else(|| ProjectSessionIndex {
        version: 1,
        project_root: project_root.to_string(),
        session_ids: Vec::new(),
        verified_empty: false,
    });
    index.verified_empty = false;
    index.session_ids.retain(|existing| existing != id);
    index.session_ids.push(id.to_string());
    let excess = index
        .session_ids
        .len()
        .saturating_sub(PROJECT_HISTORY_LIMIT);
    if excess > 0 {
        index.session_ids.drain(..excess);
    }
    write_project_index(index_path, &index)
}

fn repair_project_index(dir: &std::path::Path, project_root: &str) -> Option<SessionState> {
    // Scan outside the short index lock. A save can proceed during a slow scan;
    // its index entry is merged below before the repaired index is written.
    let entries = std::fs::read_dir(dir).ok()?;
    let mut matches = Vec::new();
    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
            continue;
        }
        let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
            continue;
        };
        if id == "latest" || id.starts_with('.') {
            continue;
        }
        let Some(session) = SessionState::load_by_id(id) else {
            continue;
        };
        if session_matches_project_root(&session, std::path::Path::new(project_root)) {
            matches.push(session);
        }
    }
    matches.sort_by_key(|session| session.updated_at);
    let latest = matches.last().cloned();
    let scanned_ids: Vec<String> = matches.iter().map(|session| session.id.clone()).collect();
    let result = with_project_index_lock(dir, project_root, |index_path| {
        // The scan ran outside the lock; a save may have indexed a session in the
        // meantime. Merge instead of overwriting so that id is never lost, but drop
        // ids whose file is gone (the reason this repair ran).
        let mut session_ids = scanned_ids;
        if let Some(current) = read_project_index(dir, project_root) {
            for id in current.session_ids {
                if validate_session_id(&id).is_err() || !dir.join(format!("{id}.json")).is_file() {
                    continue;
                }
                session_ids.retain(|existing| existing != &id);
                session_ids.push(id);
            }
        }
        let first_retained = session_ids.len().saturating_sub(PROJECT_HISTORY_LIMIT);
        session_ids.drain(..first_retained);
        write_project_index(
            index_path,
            &ProjectSessionIndex {
                version: 1,
                project_root: project_root.to_string(),
                verified_empty: session_ids.is_empty(),
                session_ids,
            },
        )?;
        Ok(latest)
    });
    if let Err(error) = &result {
        tracing::debug!("lean-ctx: session project index repair skipped: {error}");
    }
    result.ok().flatten()
}

#[cfg(unix)]
fn restrict_file_permissions(path: &std::path::Path) {
    use std::os::unix::fs::PermissionsExt;
    let perms = std::fs::Permissions::from_mode(0o600);
    let _ = std::fs::set_permissions(path, perms);
}

#[cfg(not(unix))]
fn restrict_file_permissions(_path: &std::path::Path) {}

fn validate_session_id(id: &str) -> Result<(), String> {
    if id.is_empty()
        || id == "latest"
        || id.starts_with('.')
        || id.contains('/')
        || id.contains('\\')
        || id.contains(std::path::MAIN_SEPARATOR)
    {
        return Err("invalid session id".to_string());
    }
    Ok(())
}

fn persist_session_facts(session: &SessionState) -> Result<(), String> {
    let Some(project_root) = session
        .project_root
        .as_deref()
        .filter(|project_root| !project_root.trim().is_empty())
    else {
        return Ok(());
    };

    if extract_session_facts(session).is_empty() {
        return Ok(());
    }

    // A fact derived from a finding keeps that finding's source binding, and a
    // finding that is not served now derives none: the save path must not
    // turn a bound finding into an unbound fact.
    let mut knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(project_root);
    crate::core::consolidation_engine::store_session_facts(&mut knowledge, session, false);
    knowledge.save()
}

/// Run `operation` under the request project that was bound when the save was
/// prepared. A background writer has no request scope of its own; without this
/// the admission would silently fall back to a different project authority.
fn in_request_scope<T>(
    request_project: Option<std::path::PathBuf>,
    operation: impl FnOnce() -> T,
) -> T {
    crate::core::policy::runtime::REQUEST_PROJECT
        .sync_scope(std::cell::RefCell::new(request_project), operation)
}

/// The commit side: `AdmittedSave`, the authority check inside the commit's
/// locks and the writes. Split out to keep this file under the LOC gate.
#[path = "persistence_commit.rs"]
mod commit;
pub(crate) use commit::AdmittedSave;

impl PreparedSave {
    /// Admits the detached session for storage, then writes the session data,
    /// latest pointer, compaction snapshot and project index atomically. A
    /// per-session file lock and version check make deferred saves monotonic
    /// even when background tasks finish out of order.
    ///
    /// This is the step that reads configuration and policy inputs and the
    /// disk; run it after the live session lock has been released. Nothing is
    /// written when admission refuses the session or when the admitting
    /// authority no longer holds at the commit.
    ///
    /// The lock has a deadline. A save that cannot get it reports that instead
    /// of waiting, because `SessionState::save` restores `unsaved_changes` on
    /// `Err` and the next batch tries again — whereas a caller stuck in an
    /// unbounded wait keeps whatever it holds until the other side lets go
    /// (#1783).
    pub fn write_to_disk(self) -> Result<(), String> {
        self.publish().map_err(String::from)
    }

    /// [`write_to_disk`](Self::write_to_disk) with the refusal classified.
    pub fn publish(self) -> Result<(), SaveError> {
        self.write_to_disk_with_policy(false).result
    }

    pub(super) fn write_to_disk_with_policy(self, require_new: bool) -> SaveOutcome {
        let mut outcome =
            SaveOutcome::new(&self.id, self.version, self.expected_storage_digest.clone());
        let result = self
            .admit()
            .and_then(|admitted| admitted.commit(require_new, &mut outcome));
        outcome.result = result;
        outcome
    }

    /// Storage admission of the detached copy: project-root checks, one
    /// Gateway snapshot, one verified project policy snapshot, serialization.
    /// Writes nothing. The returned save carries the admitting authority.
    pub(crate) fn admit(self) -> Result<AdmittedSave, SaveError> {
        let request_project = self.request_project.clone();
        in_request_scope(request_project, || self.admit_in_scope())
    }

    fn admit_in_scope(self) -> Result<AdmittedSave, SaveError> {
        let session = &*self.snapshot;
        if session
            .project_root
            .as_deref()
            .is_some_and(|root| normalized_safe_project_root(root).is_none())
        {
            return Err(SaveError::AdmissionRefused(
                "refusing to persist a session for a broad or unsafe project root".to_string(),
            ));
        }
        let dir = sessions_dir()
            .ok_or_else(|| SaveError::Storage("cannot determine home directory".to_string()))?;
        let (admitted, authority) = super::admission::for_storage_with_authority(session)
            .map_err(SaveError::AdmissionRefused)?;
        let compaction_snapshot = if admitted.stats.total_tool_calls > 0 {
            Some(admitted.build_compaction_snapshot())
        } else {
            None
        };
        let (json, _) = admitted.storage_json().map_err(SaveError::Storage)?;
        let pointer_json = serde_json::to_string(&LatestPointer {
            id: self.id.clone(),
        })
        .map_err(|error| SaveError::Storage(error.to_string()))?;
        Ok(AdmittedSave {
            canonical: session.canonical_checkpoint.is_some(),
            expected_storage_digest: self.expected_storage_digest.clone(),
            dir,
            id: self.id.clone(),
            version: self.version,
            json,
            pointer_json,
            compaction_snapshot,
            project_index_root: session
                .project_root
                .as_deref()
                .and_then(normalized_safe_project_root),
            request_project: self.request_project.clone(),
            authority,
        })
    }

    fn write_new_if_project_head(
        self,
        expected: Option<&(String, String)>,
    ) -> Result<Option<SaveOutcome>, String> {
        self.admit()
            .map_err(String::from)?
            .write_new_if_project_head(expected)
    }
}

impl SessionState {
    /// Return the exact persisted project head and its primary-file SHA-256.
    ///
    /// The project index is consulted first, with the existing repair scan
    /// semantics used read-only when the index is absent or stale.
    pub(crate) fn project_head(project_root: &str) -> Result<Option<(String, String)>, String> {
        let Some(project_root) = normalized_safe_project_root(project_root) else {
            return Err("unsafe project root".to_string());
        };
        let dir = sessions_dir().ok_or("cannot determine home directory")?;
        with_project_index_lock(&dir, &project_root, |_| {
            read_project_head_locked(&dir, &project_root)
        })
    }

    /// Return the current project root only when it is an absolute, non-broad
    /// root normalized with the same path rules as scoped session loading.
    pub fn strict_project_root(&self) -> Option<String> {
        self.project_root
            .as_deref()
            .and_then(normalized_strict_project_root)
    }

    /// Compare a loaded session against an already trusted normalized root.
    ///
    /// Re-normalizing the expected value keeps this helper safe for callers
    /// that do not hold the receiving-root invariant themselves.
    pub(crate) fn has_exact_project_root(&self, expected_root: &str) -> bool {
        let Some(expected_root) = normalized_strict_project_root(expected_root) else {
            return false;
        };
        self.strict_project_root()
            .is_some_and(|actual_root| actual_root == expected_root)
    }

    /// Counts locally recorded decisions from the trailing seven days.
    #[must_use]
    pub fn decision_count_this_week() -> u64 {
        let cutoff = Utc::now() - chrono::Duration::days(7);
        Self::list_sessions()
            .into_iter()
            .filter_map(|summary| Self::load_by_id(&summary.id))
            .flat_map(|session| session.decisions)
            .filter(|decision| decision.timestamp >= cutoff)
            .count()
            .try_into()
            .unwrap_or(u64::MAX)
    }

    /// Serializes and writes the session state to disk synchronously.
    pub fn save(&mut self) -> Result<(), String> {
        let prepared = self
            .prepare_save()
            .inspect_err(|_| self.note_save_failure())?;
        self.acknowledge_save(prepared.write_to_disk_with_policy(false))
    }

    /// Import publication never replaces an existing session archive. A failure
    /// after primary publication may retain a new recovery candidate, not erase
    /// the previous live session or source archive.
    pub fn save_new(&mut self) -> Result<(), String> {
        let prepared = self
            .prepare_save()
            .inspect_err(|_| self.note_save_failure())?;
        self.acknowledge_save(prepared.write_to_disk_with_policy(true))
    }

    /// Publish a new project session only when the persisted project head still
    /// equals `expected`; a conflict leaves all session files and pointers alone.
    pub(crate) fn save_new_if_project_head(
        &mut self,
        expected: Option<&(String, String)>,
    ) -> Result<bool, String> {
        self.save_new_if_project_head_with_digest(expected)
            .map(|committed| committed.is_some())
    }

    /// Publish with the existing project-head CAS and return the exact primary
    /// session file digest produced by this commit.
    pub(crate) fn save_new_if_project_head_with_digest(
        &mut self,
        expected: Option<&(String, String)>,
    ) -> Result<Option<String>, String> {
        let prepared = self
            .prepare_save()
            .inspect_err(|_| self.note_save_failure())?;
        match prepared.write_new_if_project_head(expected) {
            Ok(Some(outcome)) => {
                let digest = outcome
                    .primary_sha256
                    .clone()
                    .ok_or_else(|| "committed session digest unavailable".to_string())?;
                self.acknowledge_save(outcome)?;
                Ok(Some(digest))
            }
            Ok(None) => Ok(None),
            Err(error) => {
                self.note_save_failure();
                Err(error)
            }
        }
    }

    /// Take a detached copy of the session for a later save. Safe to call while
    /// holding the live session lock: it validates the ID and copies memory —
    /// no file, configuration or policy input is read and nothing here can
    /// wait on a policy refresh (#1783). The unsaved counter is retained until
    /// a write is acknowledged.
    ///
    /// The copy is not admitted for storage. Project-root checks, storage
    /// admission, serialization and the commit happen in
    /// `PreparedSave::write_to_disk()`, which belongs after the lock is
    /// released (a background thread, or `save_shared`). A session whose
    /// project root is unsafe, deleted or unreadable therefore prepares here
    /// and is refused there, with nothing written.
    pub fn prepare_save(&mut self) -> Result<PreparedSave, String> {
        validate_session_id(&self.id)?;
        Ok(PreparedSave {
            expected_storage_digest: self
                .canonical_checkpoint
                .as_ref()
                .and_then(|binding| binding.storage_digest.clone()),
            id: self.id.clone(),
            version: self.version,
            request_project: crate::core::policy::runtime::REQUEST_PROJECT
                .try_with(|slot| slot.borrow().clone())
                .ok()
                .flatten(),
            snapshot: Box::new(self.clone()),
        })
    }

    /// Load the bounded warm-history set for one safe project root.
    ///
    /// There is intentionally no legacy full-store fallback: cache warming is
    /// optional, while scanning every persisted session on each MCP launch is
    /// not acceptable under concurrent agent load. New saves populate the
    /// index; legacy sessions remain available through explicit session tools.
    pub fn load_recent_for_project_root(project_root: &str, limit: usize) -> Vec<Self> {
        let Some(project_root) = normalized_strict_project_root(project_root) else {
            return Vec::new();
        };
        let Some(dir) = sessions_dir() else {
            return Vec::new();
        };
        let Some(index) = std::fs::read_to_string(project_index_path(&dir, &project_root))
            .ok()
            .and_then(|json| serde_json::from_str::<ProjectSessionIndex>(&json).ok())
            .filter(|index| index.version == 1 && index.project_root == project_root)
        else {
            return Vec::new();
        };

        index
            .session_ids
            .iter()
            .rev()
            .take(limit.min(PROJECT_HISTORY_LIMIT))
            .filter_map(|id| {
                Self::load_by_id_for_project_root(id, &project_root)
                    .ok()
                    .flatten()
            })
            .collect()
    }

    /// Loads the most recent session matching the current working directory's
    /// project root.
    ///
    /// Returns `None` (a fresh session) rather than falling back to the global
    /// `latest.json` pointer: that unconditional fallback bypassed project-root
    /// matching and was the root cause of cross-project session leakage — one
    /// project's findings/decisions/knowledge bleeding into another project's
    /// first session. The correct project session is loaded later from the MCP
    /// `roots` handshake (`load_latest_for_project_root`).
    ///
    /// Also refuses to scope to a broad/unsafe cwd (e.g. the MCP daemon's HOME),
    /// which would otherwise resurrect the contaminated "HOME mega-session".
    pub fn load_latest() -> Option<Self> {
        // Inside a request its bound project is the only scope: the serving
        // process's cwd may be another project, and a request without a
        // project has no latest session (AP01 §3, AP04-G2).
        if let Ok(project) =
            crate::core::policy::runtime::REQUEST_PROJECT.try_with(|slot| slot.borrow().clone())
        {
            return Self::load_latest_for_project_root(&project?.to_string_lossy());
        }
        let cwd = std::env::current_dir().ok()?;
        if crate::core::pathutil::is_broad_or_unsafe_root(&cwd) {
            return None;
        }
        Self::load_latest_for_project_root(&cwd.to_string_lossy())
    }

    /// Loads the session referenced by the global `latest.json` pointer,
    /// regardless of project. Intended only for explicit, cross-project UX
    /// (e.g. `lean-ctx session` status from an arbitrary directory) — never for
    /// injecting knowledge into a new project's context. Prefer `load_latest`.
    pub fn load_global_latest_pointer() -> Option<Self> {
        let dir = sessions_dir()?;
        let latest_path = dir.join("latest.json");
        let pointer_json = std::fs::read_to_string(&latest_path).ok()?;
        let pointer: LatestPointer = serde_json::from_str(&pointer_json).ok()?;
        Self::load_by_id(&pointer.id)
    }

    /// Loads the most recent session matching a specific project root.
    ///
    /// A valid per-project index is the only nominal path: one index read and
    /// one session read, independent of the global session-store cardinality.
    /// A missing, corrupt, or stale index is repaired by one exceptional scan.
    pub fn load_latest_for_project_root(project_root: &str) -> Option<Self> {
        let target_root = normalized_safe_project_root(project_root)?;
        let dir = sessions_dir()?;

        if let Some(index) = read_project_index(&dir, &target_root) {
            if let Some(id) = index.session_ids.last()
                && let Some(session) = Self::load_by_id(id)
                && session_matches_project_root(&session, std::path::Path::new(&target_root))
            {
                return Some(session);
            }
            if index.session_ids.is_empty() && index.verified_empty {
                return None;
            }
        }

        repair_project_index(&dir, &target_root)
    }

    fn load_by_id_raw(id: &str) -> Option<Self> {
        validate_session_id(id).ok()?;
        let dir = sessions_dir()?;
        let path = dir.join(format!("{id}.json"));
        let json = std::fs::read_to_string(&path).ok()?;
        Self::from_storage_json(&json).ok()
    }

    /// Loads a specific session only when its persisted root already matches
    /// the receiving root; legacy normalization runs after that check.
    pub fn load_by_id_for_project_root(
        id: &str,
        expected_root: &str,
    ) -> Result<Option<Self>, SessionScopeError> {
        let Some(session) = Self::load_by_id_raw(id) else {
            return Ok(None);
        };
        if !session.has_exact_project_root(expected_root) {
            return Err(SessionScopeError::ProjectRootMismatch);
        }
        let persisted_project_root = session.project_root.clone();
        let mut normalized = normalize_loaded_session(session);
        // Keep the persisted spelling that was checked above; legacy
        // normalization may otherwise replace an admitted root from shell_cwd.
        normalized.project_root = persisted_project_root;
        Ok(Some(normalized))
    }

    /// Loads a specific session from disk by its unique ID.
    pub fn load_by_id(id: &str) -> Option<Self> {
        Self::load_by_id_raw(id).map(normalize_loaded_session)
    }

    /// Deletes a saved session and its compaction snapshot.
    ///
    /// If the deleted session is the global latest pointer, the pointer is
    /// moved to the newest remaining session or removed when none remain.
    pub fn delete_session(id: &str) -> Result<bool, String> {
        validate_session_id(id)?;
        let Some(dir) = sessions_dir() else {
            return Ok(false);
        };
        let path = dir.join(format!("{id}.json"));
        if !path.exists() {
            return Ok(false);
        }

        if let Some(session) = Self::load_by_id(id) {
            persist_session_facts(&session)?;
        }
        std::fs::remove_file(&path).map_err(|e| e.to_string())?;

        let snapshot = dir.join(format!("{id}_snapshot.txt"));
        match std::fs::remove_file(&snapshot) {
            Ok(()) => {}
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
            Err(e) => return Err(e.to_string()),
        }

        let latest_path = dir.join("latest.json");
        let points_to_deleted = std::fs::read_to_string(&latest_path)
            .ok()
            .and_then(|json| serde_json::from_str::<LatestPointer>(&json).ok())
            .is_some_and(|pointer| pointer.id == id);
        if points_to_deleted {
            if let Some(next) = Self::list_sessions().into_iter().next() {
                let latest_tmp = dir.join(".latest.json.tmp");
                let pointer_json = serde_json::to_string(&LatestPointer { id: next.id })
                    .map_err(|e| e.to_string())?;
                std::fs::write(&latest_tmp, pointer_json).map_err(|e| e.to_string())?;
                restrict_file_permissions(&latest_tmp);
                std::fs::rename(&latest_tmp, &latest_path).map_err(|e| e.to_string())?;
            } else {
                match std::fs::remove_file(&latest_path) {
                    Ok(()) => {}
                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
                    Err(e) => return Err(e.to_string()),
                }
            }
        }

        Ok(true)
    }

    /// Lists all saved sessions as summaries, sorted by most recently updated.
    pub fn list_sessions() -> Vec<SessionSummary> {
        let Some(dir) = sessions_dir() else {
            return Vec::new();
        };

        let mut summaries = Vec::new();
        if let Ok(entries) = std::fs::read_dir(&dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                if path.extension().and_then(|e| e.to_str()) != Some("json") {
                    continue;
                }
                if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
                    continue;
                }
                if let Ok(json) = std::fs::read_to_string(&path)
                    && let Ok(session) = SessionState::from_storage_json(&json)
                {
                    summaries.push(SessionSummary {
                        id: session.id,
                        started_at: session.started_at,
                        updated_at: session.updated_at,
                        version: session.version,
                        task: session.task.as_ref().map(|t| t.description.clone()),
                        tool_calls: session.stats.total_tool_calls,
                        tokens_saved: session.stats.total_tokens_saved,
                        project_root: session.project_root,
                    });
                }
            }
        }

        summaries.sort_by_key(|x| std::cmp::Reverse(x.updated_at));
        summaries
    }

    /// Scans all saved sessions for contaminated ones — those rooted at a
    /// broad/unsafe path (HOME, filesystem root, agent sandbox dir) without a
    /// real project marker, i.e. the historic "HOME mega-session" artifact.
    ///
    /// Returns `(found, quarantined)` where `found` is `(id, root)` pairs. When
    /// `apply` is true, each offending session file is moved to a
    /// `sessions/quarantine/` subdirectory (non-destructive) instead of being
    /// loaded into any project's context.
    pub fn doctor_quarantine_unsafe_roots(apply: bool) -> (Vec<(String, String)>, usize) {
        let mut found: Vec<(String, String)> = Vec::new();
        let mut quarantined = 0usize;
        let Some(dir) = sessions_dir() else {
            return (found, quarantined);
        };
        let Ok(entries) = std::fs::read_dir(&dir) else {
            return (found, quarantined);
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("json") {
                continue;
            }
            let Some(id) = path.file_stem().and_then(|n| n.to_str()) else {
                continue;
            };
            if id == "latest" || id.starts_with('.') {
                continue;
            }
            let Some(session) = Self::load_by_id(id) else {
                continue;
            };
            let Some(root) = session.project_root.as_deref() else {
                continue;
            };
            let root_path = std::path::Path::new(root);
            if crate::core::pathutil::is_broad_or_unsafe_root(root_path) {
                found.push((id.to_string(), root.to_string()));
                if apply {
                    let q_dir = dir.join("quarantine");
                    if std::fs::create_dir_all(&q_dir).is_ok()
                        && std::fs::rename(&path, q_dir.join(format!("{id}.json"))).is_ok()
                    {
                        quarantined += 1;
                    }
                }
            }
        }
        (found, quarantined)
    }

    /// Deletes sessions older than `max_age_days`, preserving the most recent
    /// session for every project root. Returns the count removed.
    ///
    /// This is an explicit retention operation, so its single store scan stays
    /// off the command hot path.
    pub fn cleanup_old_sessions(max_age_days: i64) -> u32 {
        let Some(dir) = sessions_dir() else { return 0 };
        let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
        let global_latest = Self::load_global_latest_pointer().map(|session| session.id);
        let mut sessions = Vec::new();

        if let Ok(entries) = std::fs::read_dir(&dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
                    continue;
                }
                let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
                    continue;
                };
                if id == "latest" || id.starts_with('.') {
                    continue;
                }
                if let Some(session) = Self::load_by_id(id) {
                    sessions.push((path, session));
                }
            }
        }

        let mut newest_by_project = std::collections::HashMap::new();
        for (_, session) in &sessions {
            let Some(project_root) = session
                .project_root
                .as_deref()
                .filter(|root| !root.trim().is_empty())
            else {
                continue;
            };
            newest_by_project
                .entry(project_root)
                .and_modify(|current: &mut &SessionState| {
                    if session.updated_at > current.updated_at {
                        *current = session;
                    }
                })
                .or_insert(session);
        }

        let mut retained_ids: std::collections::HashSet<String> = newest_by_project
            .values()
            .map(|session| session.id.clone())
            .collect();
        if let Some(id) = global_latest {
            retained_ids.insert(id);
        }

        sessions
            .into_iter()
            .filter(|(_, session)| {
                session.updated_at < cutoff && !retained_ids.contains(&session.id)
            })
            .filter(|(_, session)| persist_session_facts(session).is_ok())
            .filter(|(path, _)| std::fs::remove_file(path).is_ok())
            .map(|(path, session)| {
                let snapshot = path.with_file_name(format!("{}_snapshot.txt", session.id));
                let _ = std::fs::remove_file(snapshot);
            })
            .count() as u32
    }
}

#[cfg(test)]
#[path = "storage_boundary_tests.rs"]
mod storage_boundary_tests;

#[cfg(test)]
mod tests {
    use super::{
        ProjectSessionIndex, SessionState, normalized_safe_project_root, project_index_path,
        write_project_index,
    };
    use chrono::{Duration, Utc};
    use sha2::{Digest as _, Sha256};

    #[test]
    fn recent_project_sessions_use_bounded_index_without_scanning_legacy_store() {
        let _data = crate::core::data_dir::isolated_data_dir();
        let project = tempfile::tempdir().expect("project tempdir");
        let root = project.path().to_string_lossy().to_string();
        assert_eq!(SessionState::project_head(&root), Ok(None));

        for id in ["first", "second", "third"] {
            let mut session = SessionState::new();
            session.id = id.to_string();
            session.project_root = Some(root.clone());
            session.save().expect("save indexed session");
        }

        // A malformed legacy artifact proves the hot path consults only the
        // per-project index, never `list_sessions()` as a hidden fallback.
        let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
        std::fs::write(sessions.join("legacy-unreadable.json"), "not json")
            .expect("write legacy artifact");

        let ids: Vec<_> = SessionState::load_recent_for_project_root(&root, 8)
            .into_iter()
            .map(|session| session.id)
            .collect();
        assert_eq!(ids, ["third", "second", "first"]);
    }

    #[test]
    fn recent_project_sessions_refuse_broad_roots() {
        let _data = crate::core::data_dir::isolated_data_dir();
        assert!(SessionState::load_recent_for_project_root("/", 8).is_empty());
    }

    /// #1783: a save must never wait on the per-session lock indefinitely. The
    /// callers that matter hold a `tokio` guard the rest of the server needs,
    /// so an unbounded wait here is a permanent server-wide wedge; a reported
    /// failure is recoverable, because `save` re-arms `unsaved_changes` and the
    /// next batch writes.
    #[test]
    fn save_gives_up_when_another_holder_keeps_the_session_lock() {
        use fs2::FileExt;

        let _data = crate::core::data_dir::isolated_data_dir();
        let project = tempfile::tempdir().expect("project tempdir");
        let mut session = SessionState::new();
        session.id = "wedged".to_string();
        session.project_root = Some(project.path().to_string_lossy().to_string());

        let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
        std::fs::create_dir_all(&sessions).expect("create sessions dir");
        let holder = std::fs::File::create(sessions.join(".wedged.save.lock")).expect("lock file");
        holder.lock_exclusive().expect("hold the save lock");

        let started = std::time::Instant::now();
        let error = session
            .save()
            .expect_err("a held lock must not be waited out");
        let waited = started.elapsed();

        assert!(
            error.contains("lock session save"),
            "unexpected error: {error}"
        );
        assert!(
            waited < super::SAVE_LOCK_TIMEOUT * 4,
            "save must return on its own deadline, waited {waited:?}"
        );
        assert_ne!(
            session.stats.unsaved_changes, 0,
            "a failed save must stay pending so the next batch retries"
        );
        FileExt::unlock(&holder).expect("release");
    }

    #[test]
    fn broad_root_sessions_are_never_persisted() {
        let _data = crate::core::data_dir::isolated_data_dir();
        let mut session = SessionState::new();
        session.project_root = Some("/".to_string());

        let error = session
            .save()
            .expect_err("broad root save must be rejected");

        assert!(error.contains("broad or unsafe"));
        assert!(
            crate::core::session::paths::sessions_dir()
                .expect("sessions dir")
                .read_dir()
                .map_or(true, |mut entries| entries.next().is_none())
        );
    }

    #[test]
    fn latest_project_session_uses_valid_index_without_scanning_unindexed_sessions() {
        let _data = crate::core::data_dir::isolated_data_dir();
        let project = tempfile::tempdir().expect("project tempdir");
        let root = project.path().to_string_lossy().to_string();

        let mut indexed = SessionState::new();
        indexed.id = "indexed".to_string();
        indexed.project_root = Some(root.clone());
        indexed.updated_at = Utc::now() - Duration::hours(1);
        indexed.save().expect("save indexed session");

        // This valid but unindexed legacy file is newer. The nominal path must
        // trust the index and never deserialize unrelated root-level sessions.
        let mut unindexed = SessionState::new();
        unindexed.id = "unindexed".to_string();
        unindexed.project_root = Some(root.clone());
        unindexed.updated_at = Utc::now();
        let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
        std::fs::write(
            sessions.join("unindexed.json"),
            serde_json::to_string(&unindexed).expect("serialize legacy session"),
        )
        .expect("write unindexed session");

        assert_eq!(
            SessionState::load_latest_for_project_root(&root)
                .expect("load indexed session")
                .id,
            "indexed"
        );
    }

    /// AP04-G2: inside a request the latest session is the request project's,
    /// never the one of the serving process's working directory; a request
    /// without a project has none.
    #[test]
    fn latest_session_inside_a_request_follows_the_request_project() {
        let _data = crate::core::data_dir::isolated_data_dir();
        let project = tempfile::tempdir().expect("project tempdir");
        let mut session = SessionState::new();
        session.id = "request-project".to_string();
        session.project_root = Some(project.path().to_string_lossy().to_string());
        session.save().expect("save session");
        let in_request = |project: Option<std::path::PathBuf>| {
            crate::core::policy::runtime::REQUEST_PROJECT
                .sync_scope(std::cell::RefCell::new(project), SessionState::load_latest)
                .map(|session| session.id)
        };

        assert_eq!(
            in_request(Some(project.path().to_path_buf())).as_deref(),
            Some("request-project")
        );
        assert_eq!(in_request(None), None);
    }

    /// AP04-G8: a nested directory's session is not restored into its parent
    /// project because its working directory lies below the parent.
    #[test]
    fn a_nested_directorys_session_is_not_the_parent_projects() {
        let _data = crate::core::data_dir::isolated_data_dir();
        let parent = tempfile::tempdir().expect("project tempdir");
        let nested = parent.path().join("tools");
        std::fs::create_dir(&nested).expect("nested dir");
        let nested = nested.to_string_lossy().to_string();
        let mut session = SessionState::new();
        session.project_root = Some(nested.clone());
        session.shell_cwd = Some(nested);
        session.save().expect("save nested session");

        assert!(
            SessionState::load_latest_for_project_root(&parent.path().to_string_lossy()).is_none()
        );
    }

    #[test]
    fn latest_project_session_repairs_a_missing_index() {
        let _data = crate::core::data_dir::isolated_data_dir();
        let project = tempfile::tempdir().expect("project tempdir");
        let root = project.path().to_string_lossy().to_string();
        let mut session = SessionState::new();
        session.id = "repair-missing".to_string();
        session.project_root = Some(root.clone());
        session.save().expect("save indexed session");

        let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
        let canonical_root = normalized_safe_project_root(&root).expect("safe root");
        let index_path = project_index_path(&sessions, &canonical_root);
        std::fs::remove_file(&index_path).expect("remove project index");

        assert_eq!(
            SessionState::load_latest_for_project_root(&root)
                .expect("repair and load session")
                .id,
            "repair-missing"
        );
        let repaired: ProjectSessionIndex =
            serde_json::from_str(&std::fs::read_to_string(index_path).expect("repaired index"))
                .expect("valid repaired index");
        assert_eq!(repaired.session_ids, ["repair-missing"]);
    }

    #[test]
    fn latest_project_session_repairs_an_empty_index() {
        let _data = crate::core::data_dir::isolated_data_dir();
        let project = tempfile::tempdir().expect("project tempdir");
        let root = project.path().to_string_lossy().to_string();
        let mut session = SessionState::new();
        session.id = "repair-empty".to_string();
        session.project_root = Some(root.clone());
        session.save().expect("save indexed session");

        let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
        let canonical_root = normalized_safe_project_root(&root).expect("safe root");
        let index_path = project_index_path(&sessions, &canonical_root);
        write_project_index(
            &index_path,
            &ProjectSessionIndex {
                version: 1,
                project_root: canonical_root,
                session_ids: Vec::new(),
                verified_empty: false,
            },
        )
        .expect("empty project index");

        assert_eq!(
            SessionState::load_latest_for_project_root(&root)
                .expect("repair and load session")
                .id,
            "repair-empty"
        );
        let repaired: ProjectSessionIndex =
            serde_json::from_str(&std::fs::read_to_string(index_path).expect("repaired index"))
                .expect("valid repaired index");
        assert_eq!(repaired.session_ids, ["repair-empty"]);
    }

    #[test]
    fn a_scan_that_finds_no_session_is_cached_until_the_next_save() {
        let _data = crate::core::data_dir::isolated_data_dir();
        let project = tempfile::tempdir().expect("project tempdir");
        let root = project.path().to_string_lossy().to_string();
        let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
        std::fs::create_dir_all(&sessions).expect("session store exists");

        // First lookup scans the store, finds nothing and records that.
        assert!(SessionState::load_latest_for_project_root(&root).is_none());

        // A later unindexed file is not picked up: the verified-empty answer is
        // reused instead of re-parsing the whole store on every process start.
        let mut unindexed = SessionState::new();
        unindexed.id = "unindexed-after-scan".to_string();
        unindexed.project_root = Some(root.clone());
        std::fs::write(
            sessions.join("unindexed-after-scan.json"),
            serde_json::to_string(&unindexed).expect("serialize session"),
        )
        .expect("write unindexed session");
        assert!(SessionState::load_latest_for_project_root(&root).is_none());

        // A real save indexes the session and ends the empty state.
        let mut saved = SessionState::new();
        saved.id = "saved-after-scan".to_string();
        saved.project_root = Some(root.clone());
        saved.save().expect("save session");
        assert_eq!(
            SessionState::load_latest_for_project_root(&root)
                .expect("saved session")
                .id,
            "saved-after-scan"
        );
    }

    #[test]
    fn latest_project_session_repairs_a_corrupt_index() {
        let _data = crate::core::data_dir::isolated_data_dir();
        let project = tempfile::tempdir().expect("project tempdir");
        let root = project.path().to_string_lossy().to_string();
        let mut session = SessionState::new();
        session.id = "repair-corrupt".to_string();
        session.project_root = Some(root.clone());
        session.save().expect("save indexed session");

        let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
        let canonical_root = normalized_safe_project_root(&root).expect("safe root");
        let index_path = project_index_path(&sessions, &canonical_root);
        std::fs::write(&index_path, "not json").expect("corrupt project index");

        assert_eq!(
            SessionState::load_latest_for_project_root(&root)
                .expect("repair and load session")
                .id,
            "repair-corrupt"
        );
        let repaired: ProjectSessionIndex =
            serde_json::from_str(&std::fs::read_to_string(index_path).expect("repaired index"))
                .expect("valid repaired index");
        assert_eq!(repaired.session_ids, ["repair-corrupt"]);
    }

    #[test]
    fn latest_project_session_repairs_an_index_with_a_missing_session() {
        let _data = crate::core::data_dir::isolated_data_dir();
        let project = tempfile::tempdir().expect("project tempdir");
        let root = project.path().to_string_lossy().to_string();

        let mut older = SessionState::new();
        older.id = "repair-older".to_string();
        older.project_root = Some(root.clone());
        older.updated_at = Utc::now() - Duration::hours(1);
        older.save().expect("save older session");

        let mut missing = SessionState::new();
        missing.id = "repair-missing-file".to_string();
        missing.project_root = Some(root.clone());
        missing.save().expect("save missing session");
        let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
        std::fs::remove_file(sessions.join("repair-missing-file.json"))
            .expect("remove indexed session");

        assert_eq!(
            SessionState::load_latest_for_project_root(&root)
                .expect("repair and load fallback")
                .id,
            "repair-older"
        );
        let repaired: ProjectSessionIndex = serde_json::from_str(
            &std::fs::read_to_string(project_index_path(
                &sessions,
                &normalized_safe_project_root(&root).expect("safe root"),
            ))
            .expect("repaired index"),
        )
        .expect("valid repaired index");
        assert_eq!(repaired.session_ids, ["repair-older"]);
    }

    #[test]
    fn cleanup_old_sessions_preserves_the_latest_session_for_each_project() {
        let _data = crate::core::data_dir::isolated_data_dir();
        let project_a = tempfile::tempdir().expect("project A");
        let project_b = tempfile::tempdir().expect("project B");
        let root_a = project_a.path().to_string_lossy().to_string();
        let root_b = project_b.path().to_string_lossy().to_string();

        for (id, root, age_days) in [
            ("a-old", root_a.as_str(), 10),
            ("a-latest", root_a.as_str(), 8),
            ("b-old", root_b.as_str(), 10),
            ("b-latest", root_b.as_str(), 8),
        ] {
            let mut session = SessionState::new();
            session.id = id.to_string();
            session.project_root = Some(root.to_string());
            session.updated_at = Utc::now() - Duration::days(age_days);
            session.save().expect("save session");
        }

        assert_eq!(SessionState::cleanup_old_sessions(7), 2);
        assert!(SessionState::load_by_id("a-latest").is_some());
        assert!(SessionState::load_by_id("b-latest").is_some());
        assert!(SessionState::load_by_id("a-old").is_none());
        assert!(SessionState::load_by_id("b-old").is_none());
    }

    #[test]
    fn deferred_save_cannot_replace_a_newer_session_version() {
        let _data = crate::core::data_dir::isolated_data_dir();
        let project = tempfile::tempdir().expect("project root");
        let project_root = project.path().to_string_lossy().into_owned();
        let mut session = SessionState::new();
        session.project_root = Some(project_root.clone());
        session.shell_cwd = Some(project_root);
        let id = session.id.clone();
        let older = session.prepare_save().expect("prepare older save");
        session.increment();
        let expected_version = session.version;
        let newer = session.prepare_save().expect("prepare newer save");

        newer.write_to_disk().expect("write newer save");
        older.write_to_disk().expect("skip older save");

        assert_eq!(
            SessionState::load_by_id(&id)
                .expect("load persisted session")
                .version,
            expected_version
        );
    }

    #[test]
    fn project_head_cas_conflict_preserves_live_files_and_success_returns_exact_digest() {
        let _data = crate::core::data_dir::isolated_data_dir();
        let project = tempfile::tempdir().expect("project tempdir");
        let root = project.path().to_string_lossy().to_string();

        let mut initial = SessionState::new();
        initial.id = "cas-initial".to_string();
        initial.project_root = Some(root.clone());
        initial.save().expect("save initial project session");
        let stale_head = SessionState::project_head(&root)
            .expect("read initial project head")
            .expect("initial head exists");

        let mut local = SessionState::new();
        local.id = "cas-local".to_string();
        local.project_root = Some(root.clone());
        local.save().expect("save competing local session");

        let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
        let latest_path = sessions.join("latest.json");
        let index_path = project_index_path(
            &sessions,
            &normalized_safe_project_root(&root).expect("safe root"),
        );
        let latest_before_conflict = std::fs::read(&latest_path).expect("latest pointer");
        let index_before_conflict = std::fs::read(&index_path).expect("project index");

        let mut candidate = SessionState::new();
        candidate.id = "cas-candidate".to_string();
        candidate.project_root = Some(root.clone());
        assert!(
            !candidate
                .save_new_if_project_head(Some(&stale_head))
                .expect("stale head is a conflict")
        );
        assert!(!sessions.join("cas-candidate.json").exists());
        assert_eq!(
            std::fs::read(&latest_path).expect("latest after conflict"),
            latest_before_conflict
        );
        assert_eq!(
            std::fs::read(&index_path).expect("index after conflict"),
            index_before_conflict
        );

        let current_head = SessionState::project_head(&root)
            .expect("read current project head")
            .expect("current head exists");
        assert!(
            candidate
                .save_new_if_project_head(Some(&current_head))
                .expect("publish against current head")
        );
        let persisted =
            std::fs::read(sessions.join("cas-candidate.json")).expect("published primary");
        assert_eq!(
            SessionState::project_head(&root).expect("read published project head"),
            Some((
                "cas-candidate".to_string(),
                format!("sha256:{}", hex::encode(Sha256::digest(&persisted))),
            ))
        );
    }
}