1use std::{
6 collections::BTreeMap,
7 fs::File,
8 path::{Path, PathBuf},
9};
10
11use anyhow::Context as _;
12use kcode_session_control_journal::{Journal, Record};
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15
16const LIFECYCLE_SIDEBAND: &str = "session_lifecycle";
17const COMMAND_SIDEBAND: &str = "session_command";
18const STOP_SIDEBAND: &str = "session_stop";
19const CONTROL_EXTENSION: &str = "session-control";
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum OpenMode {
23 CreateNew,
24 OpenOrCreate,
25 ExistingOnly,
26}
27
28#[derive(Clone, Debug, Default)]
29pub struct ControlProjection {
30 pub lifecycle: Option<SessionRecord>,
31 pub commands: BTreeMap<String, SessionCommand>,
32 pub stop_requests: BTreeMap<String, SessionStopRequest>,
33}
34
35#[derive(Clone, Debug)]
36pub enum ControlUpdate {
37 Lifecycle(SessionRecord),
38 Command(SessionCommand),
39 StopRequest(SessionStopRequest),
40}
41
42impl ControlUpdate {
43 pub fn projected(mut self) -> Self {
47 if let Self::Lifecycle(record) = &mut self {
48 record.state = control_state(&record.state);
49 }
50 self
51 }
52}
53
54#[derive(Clone, Debug, Deserialize, Serialize)]
55pub struct SessionRecord {
56 pub id: String,
57 pub phase: String,
58 pub started_at: String,
59 pub updated_at: String,
60 pub state: Value,
61 pub provenance_id: Option<String>,
62 pub version: i64,
63 pub last_user_message_at: Option<String>,
64 pub ended_at: Option<String>,
65 pub ingress_failure_count: i64,
66 pub ingress_failures: Value,
67 pub ingress_next_attempt_at: Option<String>,
68 #[serde(default, skip_serializing_if = "is_false")]
69 pub summary: bool,
70}
71
72#[derive(Clone, Debug, Deserialize, Serialize)]
73#[serde(rename_all = "camelCase")]
74pub struct SessionCommand {
75 pub id: String,
76 pub conversation_id: String,
77 pub sequence: i64,
78 pub kind: String,
79 pub payload: Value,
80 pub status: String,
81 pub cancel_requested: bool,
82 pub outcome: Option<Value>,
83 pub created_at: String,
84 pub processing_started_at: Option<String>,
85 pub completed_at: Option<String>,
86 pub idempotency_id: String,
87}
88
89#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
90#[serde(rename_all = "camelCase")]
91pub struct SessionStopRequest {
92 pub id: String,
93 pub session_id: String,
94 pub scope: String,
95 pub status: String,
96 pub outcome: Option<Value>,
97 pub requested_at: String,
98 pub completed_at: Option<String>,
99 pub idempotency_id: String,
100}
101
102pub struct SessionControl {
103 directory: PathBuf,
104 path: PathBuf,
105 journal: Journal,
106}
107
108impl SessionControl {
109 pub fn open(
110 directory: impl AsRef<Path>,
111 session_id: &str,
112 mode: OpenMode,
113 ) -> anyhow::Result<Option<Self>> {
114 let directory = directory.as_ref().to_path_buf();
115 let path = control_path(&directory, session_id);
116 let journal = match mode {
117 OpenMode::CreateNew => Journal::create(path.clone())?,
118 OpenMode::OpenOrCreate => match Journal::open(path.clone())? {
119 Some(journal) => journal,
120 None => Journal::create(path.clone())?,
121 },
122 OpenMode::ExistingOnly => {
123 let Some(journal) = Journal::open(path.clone())? else {
124 return Ok(None);
125 };
126 journal
127 }
128 };
129 Ok(Some(Self {
130 directory,
131 path,
132 journal,
133 }))
134 }
135
136 pub fn projection(&self) -> ControlProjection {
137 project_records(self.journal.records())
138 }
139
140 pub fn append(
141 &mut self,
142 recorded_at: impl Into<String>,
143 update: ControlUpdate,
144 ) -> anyhow::Result<ControlUpdate> {
145 let update = update.projected();
146 let (kind, value) = match &update {
147 ControlUpdate::Lifecycle(record) => (
148 LIFECYCLE_SIDEBAND,
149 serde_json::to_value(record).context("encoding session lifecycle record")?,
150 ),
151 ControlUpdate::Command(command) => (
152 COMMAND_SIDEBAND,
153 serde_json::to_value(command).context("encoding session command record")?,
154 ),
155 ControlUpdate::StopRequest(request) => (
156 STOP_SIDEBAND,
157 serde_json::to_value(request).context("encoding session stop record")?,
158 ),
159 };
160 self.journal.append(kind, recorded_at, value)?;
161 Ok(update)
162 }
163
164 pub fn delete(self) -> anyhow::Result<()> {
165 let Self {
166 directory,
167 path,
168 journal,
169 } = self;
170 drop(journal);
171 if path.exists() {
172 std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
173 sync_directory(&directory)?;
174 }
175 Ok(())
176 }
177
178 pub fn compact_directory(directory: impl AsRef<Path>) -> anyhow::Result<()> {
179 let directory = directory.as_ref();
180 let mut paths = std::fs::read_dir(directory)?
181 .filter_map(Result::ok)
182 .map(|entry| entry.path())
183 .filter(|path| {
184 path.extension().and_then(|value| value.to_str()) == Some(CONTROL_EXTENSION)
185 })
186 .collect::<Vec<_>>();
187 paths.sort();
188 for path in paths {
189 compact_journal(&path)?;
190 }
191 Ok(())
192 }
193}
194
195fn is_false(value: &bool) -> bool {
196 !*value
197}
198
199fn control_path(directory: &Path, session_id: &str) -> PathBuf {
200 directory.join(format!("{session_id}.{CONTROL_EXTENSION}"))
201}
202
203fn project_records(records: &[Record]) -> ControlProjection {
204 let latest_lifecycle = records
205 .iter()
206 .rev()
207 .find(|record| record.kind == LIFECYCLE_SIDEBAND)
208 .and_then(|record| serde_json::from_value(record.value.clone()).ok());
209 let mut commands = BTreeMap::new();
210 let mut stop_requests = BTreeMap::new();
211 for record in records {
212 match record.kind.as_str() {
213 COMMAND_SIDEBAND => {
214 if let Ok(command) = serde_json::from_value::<SessionCommand>(record.value.clone())
215 {
216 commands.insert(command.id.clone(), command);
217 }
218 }
219 STOP_SIDEBAND => {
220 if let Ok(request) =
221 serde_json::from_value::<SessionStopRequest>(record.value.clone())
222 {
223 stop_requests.insert(request.id.clone(), request);
224 }
225 }
226 _ => {}
227 }
228 }
229 ControlProjection {
230 lifecycle: latest_lifecycle,
231 commands,
232 stop_requests,
233 }
234}
235
236fn control_state(value: &Value) -> Value {
237 const KEYS: &[&str] = &[
238 "format",
239 "version",
240 "stateVersion",
241 "sessionId",
242 "sessionType",
243 "sourceSessionType",
244 "channel",
245 "freeTime",
246 "selfTimeIntent",
247 "orchestration",
248 "provenanceId",
249 "rustLibSessionId",
250 "rootNodeIds",
251 "referenceRootNodeIds",
252 "startedAt",
253 "pendingTurn",
254 "pendingExternalEventId",
255 "roundsUsed",
256 "providerAffinity",
257 "nextThreadResetReason",
258 "completed",
259 "sessionObjectId",
260 "commitReceipt",
261 "commitAuthor",
262 "providerModel",
263 "kwebPlan",
264 "startIdempotencyId",
265 "ingressSource",
266 "firstUserMessage",
267 "boxCount",
268 "eventCount",
269 "chatendMetadata",
270 "sessionStatus",
271 "historyIngress",
272 ];
273 let mut output = Map::new();
274 for key in KEYS {
275 if let Some(item) = value.get(*key) {
276 if *key == "commitReceipt" && item.is_null() {
277 continue;
278 }
279 let item = if *key == "historyIngress" {
280 control_state(item)
281 } else {
282 item.clone()
283 };
284 output.insert((*key).into(), item);
285 }
286 }
287 Value::Object(output)
288}
289
290fn compact_journal(path: &Path) -> anyhow::Result<()> {
291 let original_bytes = std::fs::metadata(path)
292 .with_context(|| format!("reading metadata for {}", path.display()))?
293 .len();
294 if original_bytes >= 16 * 1024 * 1024 {
295 tracing::info!(
296 path = %path.display(),
297 original_bytes,
298 "Compacting legacy Session History control journal"
299 );
300 }
301
302 let mut journal = Journal::open(path.to_path_buf())?
303 .with_context(|| format!("session-control journal {} disappeared", path.display()))?;
304 let repaired_bytes = std::fs::metadata(path)?.len();
305 let tail_repaired = repaired_bytes != original_bytes;
306 let mut latest_lifecycle = None;
307 let mut latest_commands = BTreeMap::<String, (u64, Record)>::new();
308 let mut latest_stop_requests = BTreeMap::<String, (u64, Record)>::new();
309 let mut retained_other = Vec::new();
310 let mut needs_rewrite = false;
311
312 for (sequence, mut record) in journal.records().iter().cloned().enumerate() {
313 let sequence = sequence as u64;
314 match record.kind.as_str() {
315 LIFECYCLE_SIDEBAND => {
316 if let Some(state) = record.value.get_mut("state") {
317 let projected = control_state(state);
318 if *state != projected {
319 *state = projected;
320 needs_rewrite = true;
321 }
322 }
323 if latest_lifecycle.replace((sequence, record)).is_some() {
324 needs_rewrite = true;
325 }
326 }
327 COMMAND_SIDEBAND => {
328 let id = record
329 .value
330 .get("id")
331 .and_then(Value::as_str)
332 .context("session command record has no ID")?
333 .to_owned();
334 if latest_commands.insert(id, (sequence, record)).is_some() {
335 needs_rewrite = true;
336 }
337 }
338 STOP_SIDEBAND => {
339 let id = record
340 .value
341 .get("id")
342 .and_then(Value::as_str)
343 .context("session stop record has no ID")?
344 .to_owned();
345 if latest_stop_requests
346 .insert(id, (sequence, record))
347 .is_some()
348 {
349 needs_rewrite = true;
350 }
351 }
352 _ => retained_other.push((sequence, record)),
353 }
354 }
355
356 if needs_rewrite {
357 let mut retained = retained_other;
358 retained.extend(latest_lifecycle);
359 retained.extend(latest_commands.into_values());
360 retained.extend(latest_stop_requests.into_values());
361 retained.sort_by_key(|(sequence, _)| *sequence);
362 journal.replace(retained.into_iter().map(|(_, record)| record))?;
363 }
364
365 if needs_rewrite || tail_repaired {
366 tracing::info!(
367 path = %path.display(),
368 original_bytes,
369 compacted_bytes = std::fs::metadata(path)?.len(),
370 "Compacted Session History control journal"
371 );
372 }
373 Ok(())
374}
375
376fn sync_directory(path: &Path) -> anyhow::Result<()> {
377 File::open(path)
378 .with_context(|| format!("opening directory {} for sync", path.display()))?
379 .sync_all()
380 .with_context(|| format!("syncing directory {}", path.display()))
381}
382
383#[cfg(test)]
384mod tests {
385 use std::{
386 fs::{self, OpenOptions},
387 io::Write as _,
388 sync::atomic::{AtomicU64, Ordering},
389 time::{SystemTime, UNIX_EPOCH},
390 };
391
392 use serde_json::json;
393
394 use super::*;
395
396 static NEXT_ROOT: AtomicU64 = AtomicU64::new(0);
397
398 fn root(label: &str) -> PathBuf {
399 let path = std::env::temp_dir().join(format!(
400 "kcode-session-control-state-{label}-{}-{}-{}",
401 std::process::id(),
402 SystemTime::now()
403 .duration_since(UNIX_EPOCH)
404 .unwrap()
405 .as_nanos(),
406 NEXT_ROOT.fetch_add(1, Ordering::Relaxed),
407 ));
408 fs::create_dir(&path).unwrap();
409 path
410 }
411
412 fn lifecycle(id: &str, version: i64, state: Value) -> SessionRecord {
413 SessionRecord {
414 id: id.into(),
415 phase: "active".into(),
416 started_at: "2026-08-02T00:00:00Z".into(),
417 updated_at: format!("2026-08-02T00:00:0{version}Z"),
418 state,
419 provenance_id: None,
420 version,
421 last_user_message_at: None,
422 ended_at: None,
423 ingress_failure_count: 0,
424 ingress_failures: json!([]),
425 ingress_next_attempt_at: None,
426 summary: false,
427 }
428 }
429
430 fn command(id: &str, status: &str, sequence: i64) -> SessionCommand {
431 SessionCommand {
432 id: id.into(),
433 conversation_id: "session-1".into(),
434 sequence,
435 kind: "message".into(),
436 payload: json!({"text":"hello"}),
437 status: status.into(),
438 cancel_requested: false,
439 outcome: None,
440 created_at: "2026-08-02T00:00:00Z".into(),
441 processing_started_at: None,
442 completed_at: None,
443 idempotency_id: format!("command-{id}"),
444 }
445 }
446
447 fn stop(id: &str, status: &str) -> SessionStopRequest {
448 SessionStopRequest {
449 id: id.into(),
450 session_id: "session-1".into(),
451 scope: "turn".into(),
452 status: status.into(),
453 outcome: None,
454 requested_at: "2026-08-02T00:00:00Z".into(),
455 completed_at: None,
456 idempotency_id: format!("stop-{id}"),
457 }
458 }
459
460 #[test]
461 fn opening_modes_preserve_create_and_absence_distinctions() {
462 let root = root("open-modes");
463 assert!(
464 SessionControl::open(&root, "missing", OpenMode::ExistingOnly)
465 .unwrap()
466 .is_none()
467 );
468
469 let created = SessionControl::open(&root, "new", OpenMode::CreateNew)
470 .unwrap()
471 .unwrap();
472 assert!(control_path(&root, "new").is_file());
473 assert!(SessionControl::open(&root, "new", OpenMode::CreateNew).is_err());
474 drop(created);
475
476 let opened = SessionControl::open(&root, "new", OpenMode::OpenOrCreate)
477 .unwrap()
478 .unwrap();
479 drop(opened);
480 let created_on_absence = SessionControl::open(&root, "other", OpenMode::OpenOrCreate)
481 .unwrap()
482 .unwrap();
483 assert!(control_path(&root, "other").is_file());
484 drop(created_on_absence);
485 fs::remove_dir_all(root).unwrap();
486 }
487
488 #[test]
489 fn typed_append_projects_recursive_lifecycle_state_and_latest_values() {
490 let root = root("typed-projection");
491 let mut control = SessionControl::open(&root, "session-1", OpenMode::CreateNew)
492 .unwrap()
493 .unwrap();
494 let provider_affinity = json!({
495 "continuation": {"thread_id": "thread-1"},
496 "synchronized_event_id": 42,
497 "material_fingerprint": "material-1"
498 });
499 let persisted = control
500 .append(
501 "t1",
502 ControlUpdate::Lifecycle(lifecycle(
503 "session-1",
504 1,
505 json!({
506 "sessionType":"conversation",
507 "firstUserMessage":"hello",
508 "boxCount":2,
509 "eventCount":3,
510 "providerAffinity":provider_affinity.clone(),
511 "nextThreadResetReason":"provider_material_changed",
512 "chatendText":"discard",
513 "commitReceipt":null,
514 "historyIngress":{
515 "completed":true,
516 "commitReceipt":{"sessionObjectId":"A1234567"},
517 "boxes":{"1":{"text":"discard"}},
518 "chatendText":"discard"
519 }
520 }),
521 )),
522 )
523 .unwrap();
524 let ControlUpdate::Lifecycle(persisted) = persisted else {
525 panic!("append changed update kind");
526 };
527 assert_eq!(persisted.state["sessionType"], "conversation");
528 assert_eq!(persisted.state["firstUserMessage"], "hello");
529 assert_eq!(persisted.state["boxCount"], 2);
530 assert_eq!(persisted.state["eventCount"], 3);
531 assert_eq!(persisted.state["providerAffinity"], provider_affinity);
532 assert_eq!(
533 persisted.state["nextThreadResetReason"],
534 "provider_material_changed"
535 );
536 assert!(persisted.state.get("chatendText").is_none());
537 assert!(persisted.state.get("commitReceipt").is_none());
538 assert_eq!(
539 persisted.state["historyIngress"]["commitReceipt"]["sessionObjectId"],
540 "A1234567"
541 );
542 assert!(
543 persisted.state["historyIngress"]
544 .get("chatendText")
545 .is_none()
546 );
547 assert!(persisted.state["historyIngress"].get("boxes").is_none());
548
549 drop(control);
550 let mut control = SessionControl::open(&root, "session-1", OpenMode::ExistingOnly)
551 .unwrap()
552 .unwrap();
553 let restored = control.projection().lifecycle.unwrap();
554 assert_eq!(restored.state["providerAffinity"], provider_affinity);
555 assert_eq!(
556 restored.state["nextThreadResetReason"],
557 "provider_material_changed"
558 );
559
560 control
561 .append(
562 "t2",
563 ControlUpdate::Lifecycle(lifecycle(
564 "session-1",
565 2,
566 json!({"sessionType":"conversation","pendingTurn":true}),
567 )),
568 )
569 .unwrap();
570 control
571 .append("t3", ControlUpdate::Command(command("a", "pending", 1)))
572 .unwrap();
573 control
574 .append("t4", ControlUpdate::Command(command("a", "complete", 1)))
575 .unwrap();
576 control
577 .append("t5", ControlUpdate::Command(command("b", "pending", 2)))
578 .unwrap();
579 control
580 .append("t6", ControlUpdate::StopRequest(stop("s", "pending")))
581 .unwrap();
582 control
583 .append("t7", ControlUpdate::StopRequest(stop("s", "complete")))
584 .unwrap();
585
586 let projection = control.projection();
587 assert_eq!(projection.lifecycle.unwrap().version, 2);
588 assert_eq!(projection.commands.len(), 2);
589 assert_eq!(projection.commands["a"].status, "complete");
590 assert_eq!(projection.commands["b"].status, "pending");
591 assert_eq!(projection.stop_requests.len(), 1);
592 assert_eq!(projection.stop_requests["s"].status, "complete");
593 drop(control);
594
595 let reopened = SessionControl::open(&root, "session-1", OpenMode::ExistingOnly)
596 .unwrap()
597 .unwrap();
598 assert_eq!(reopened.projection().lifecycle.unwrap().version, 2);
599 drop(reopened);
600 fs::remove_dir_all(root).unwrap();
601 }
602
603 #[test]
604 fn startup_compaction_retains_unknown_kinds_and_survivor_order() {
605 let root = root("compaction-order");
606 let path = control_path(&root, "session-1");
607 let mut journal = Journal::create(path.clone()).unwrap();
608 journal
609 .append("unknown-first", "t0", json!({"value":0}))
610 .unwrap();
611 journal
612 .append(
613 LIFECYCLE_SIDEBAND,
614 "t1",
615 serde_json::to_value(lifecycle(
616 "session-1",
617 1,
618 json!({"sessionType":"conversation","chatendText":"old"}),
619 ))
620 .unwrap(),
621 )
622 .unwrap();
623 journal
624 .append(
625 COMMAND_SIDEBAND,
626 "t2",
627 serde_json::to_value(command("a", "pending", 1)).unwrap(),
628 )
629 .unwrap();
630 journal
631 .append("unknown-middle", "t3", json!({"value":3}))
632 .unwrap();
633 journal
634 .append(
635 LIFECYCLE_SIDEBAND,
636 "t4",
637 serde_json::to_value(lifecycle(
638 "session-1",
639 2,
640 json!({
641 "sessionType":"conversation",
642 "pendingTurn":true,
643 "historyIngress":{"chatendText":"discard","completed":true}
644 }),
645 ))
646 .unwrap(),
647 )
648 .unwrap();
649 journal
650 .append(
651 STOP_SIDEBAND,
652 "t5",
653 serde_json::to_value(stop("s", "pending")).unwrap(),
654 )
655 .unwrap();
656 journal
657 .append(
658 COMMAND_SIDEBAND,
659 "t6",
660 serde_json::to_value(command("a", "complete", 1)).unwrap(),
661 )
662 .unwrap();
663 journal
664 .append("unknown-last", "t7", json!({"value":7}))
665 .unwrap();
666 journal
667 .append(
668 STOP_SIDEBAND,
669 "t8",
670 serde_json::to_value(stop("s", "complete")).unwrap(),
671 )
672 .unwrap();
673 drop(journal);
674
675 SessionControl::compact_directory(&root).unwrap();
676
677 let compacted = Journal::open(path).unwrap().unwrap();
678 let kinds = compacted
679 .records()
680 .iter()
681 .map(|record| record.kind.as_str())
682 .collect::<Vec<_>>();
683 assert_eq!(
684 kinds,
685 [
686 "unknown-first",
687 "unknown-middle",
688 LIFECYCLE_SIDEBAND,
689 COMMAND_SIDEBAND,
690 "unknown-last",
691 STOP_SIDEBAND,
692 ]
693 );
694 let projected = project_records(compacted.records());
695 assert_eq!(projected.lifecycle.as_ref().unwrap().version, 2);
696 assert_eq!(
697 projected.lifecycle.unwrap().state["historyIngress"]["completed"],
698 true
699 );
700 assert_eq!(projected.commands["a"].status, "complete");
701 assert_eq!(projected.stop_requests["s"].status, "complete");
702 drop(compacted);
703 fs::remove_dir_all(root).unwrap();
704 }
705
706 #[test]
707 fn malformed_typed_records_keep_the_existing_tolerance_and_errors() {
708 let root = root("malformed");
709 let path = control_path(&root, "session-1");
710 let mut journal = Journal::create(path.clone()).unwrap();
711 journal
712 .append(
713 LIFECYCLE_SIDEBAND,
714 "t1",
715 serde_json::to_value(lifecycle("session-1", 1, json!({}))).unwrap(),
716 )
717 .unwrap();
718 journal
719 .append(LIFECYCLE_SIDEBAND, "t2", json!({"not":"a lifecycle"}))
720 .unwrap();
721 journal
722 .append(COMMAND_SIDEBAND, "t3", json!({"id":"partial"}))
723 .unwrap();
724 drop(journal);
725
726 let control = SessionControl::open(&root, "session-1", OpenMode::ExistingOnly)
727 .unwrap()
728 .unwrap();
729 let projection = control.projection();
730 assert!(projection.lifecycle.is_none());
731 assert!(projection.commands.is_empty());
732 drop(control);
733
734 assert!(SessionControl::compact_directory(&root).is_ok());
735
736 let malformed_path = control_path(&root, "missing-id");
737 let mut malformed = Journal::create(malformed_path).unwrap();
738 malformed
739 .append(COMMAND_SIDEBAND, "t1", json!({"status":"pending"}))
740 .unwrap();
741 drop(malformed);
742 assert!(
743 SessionControl::compact_directory(&root)
744 .unwrap_err()
745 .to_string()
746 .contains("session command record has no ID")
747 );
748 fs::remove_dir_all(root).unwrap();
749 }
750
751 #[test]
752 fn opening_repairs_incomplete_tail_but_rejects_complete_corruption() {
753 let root = root("integrity");
754 let path = control_path(&root, "tail");
755 let mut control = SessionControl::open(&root, "tail", OpenMode::CreateNew)
756 .unwrap()
757 .unwrap();
758 control
759 .append(
760 "t1",
761 ControlUpdate::Lifecycle(lifecycle("tail", 1, json!({}))),
762 )
763 .unwrap();
764 drop(control);
765 let complete = fs::read(&path).unwrap();
766 let mut file = OpenOptions::new().append(true).open(&path).unwrap();
767 file.write_all(b"incomplete tail").unwrap();
768 file.sync_all().unwrap();
769 drop(file);
770 let repaired = SessionControl::open(&root, "tail", OpenMode::ExistingOnly)
771 .unwrap()
772 .unwrap();
773 assert_eq!(repaired.projection().lifecycle.unwrap().version, 1);
774 drop(repaired);
775 assert_eq!(fs::read(&path).unwrap(), complete);
776
777 let corrupt_path = control_path(&root, "corrupt");
778 let mut corrupt = SessionControl::open(&root, "corrupt", OpenMode::CreateNew)
779 .unwrap()
780 .unwrap();
781 corrupt
782 .append(
783 "t1",
784 ControlUpdate::Lifecycle(lifecycle("corrupt", 1, json!({}))),
785 )
786 .unwrap();
787 drop(corrupt);
788 let mut bytes = fs::read(&corrupt_path).unwrap();
789 bytes[0] = if bytes[0] == b'0' { b'1' } else { b'0' };
790 fs::write(&corrupt_path, bytes).unwrap();
791 assert!(SessionControl::open(&root, "corrupt", OpenMode::ExistingOnly).is_err());
792 fs::remove_dir_all(root).unwrap();
793 }
794
795 #[test]
796 fn delete_removes_only_the_control_file() {
797 let root = root("delete");
798 let unrelated = root.join("keep.session-log");
799 fs::write(&unrelated, b"log").unwrap();
800 let control = SessionControl::open(&root, "session-1", OpenMode::CreateNew)
801 .unwrap()
802 .unwrap();
803 let path = control_path(&root, "session-1");
804 control.delete().unwrap();
805 assert!(!path.exists());
806 assert_eq!(fs::read(unrelated).unwrap(), b"log");
807 fs::remove_dir_all(root).unwrap();
808 }
809}