Skip to main content

kcode_session_control_state/
lib.rs

1//! Typed state and startup compaction for one `.session-control` journal.
2//!
3//! Application policy and coordination remain with the composing session owner.
4
5use 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    /// Projects lifecycle state to the authoritative control fields.
44    ///
45    /// Command and stop updates are returned unchanged.
46    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        "completed",
257        "sessionObjectId",
258        "commitReceipt",
259        "commitAuthor",
260        "providerModel",
261        "kwebPlan",
262        "startIdempotencyId",
263        "ingressSource",
264        "chatendMetadata",
265        "sessionStatus",
266        "historyIngress",
267    ];
268    let mut output = Map::new();
269    for key in KEYS {
270        if let Some(item) = value.get(*key) {
271            if *key == "commitReceipt" && item.is_null() {
272                continue;
273            }
274            let item = if *key == "historyIngress" {
275                control_state(item)
276            } else {
277                item.clone()
278            };
279            output.insert((*key).into(), item);
280        }
281    }
282    Value::Object(output)
283}
284
285fn compact_journal(path: &Path) -> anyhow::Result<()> {
286    let original_bytes = std::fs::metadata(path)
287        .with_context(|| format!("reading metadata for {}", path.display()))?
288        .len();
289    if original_bytes >= 16 * 1024 * 1024 {
290        tracing::info!(
291            path = %path.display(),
292            original_bytes,
293            "Compacting legacy Session History control journal"
294        );
295    }
296
297    let mut journal = Journal::open(path.to_path_buf())?
298        .with_context(|| format!("session-control journal {} disappeared", path.display()))?;
299    let repaired_bytes = std::fs::metadata(path)?.len();
300    let tail_repaired = repaired_bytes != original_bytes;
301    let mut latest_lifecycle = None;
302    let mut latest_commands = BTreeMap::<String, (u64, Record)>::new();
303    let mut latest_stop_requests = BTreeMap::<String, (u64, Record)>::new();
304    let mut retained_other = Vec::new();
305    let mut needs_rewrite = false;
306
307    for (sequence, mut record) in journal.records().iter().cloned().enumerate() {
308        let sequence = sequence as u64;
309        match record.kind.as_str() {
310            LIFECYCLE_SIDEBAND => {
311                if let Some(state) = record.value.get_mut("state") {
312                    let projected = control_state(state);
313                    if *state != projected {
314                        *state = projected;
315                        needs_rewrite = true;
316                    }
317                }
318                if latest_lifecycle.replace((sequence, record)).is_some() {
319                    needs_rewrite = true;
320                }
321            }
322            COMMAND_SIDEBAND => {
323                let id = record
324                    .value
325                    .get("id")
326                    .and_then(Value::as_str)
327                    .context("session command record has no ID")?
328                    .to_owned();
329                if latest_commands.insert(id, (sequence, record)).is_some() {
330                    needs_rewrite = true;
331                }
332            }
333            STOP_SIDEBAND => {
334                let id = record
335                    .value
336                    .get("id")
337                    .and_then(Value::as_str)
338                    .context("session stop record has no ID")?
339                    .to_owned();
340                if latest_stop_requests
341                    .insert(id, (sequence, record))
342                    .is_some()
343                {
344                    needs_rewrite = true;
345                }
346            }
347            _ => retained_other.push((sequence, record)),
348        }
349    }
350
351    if needs_rewrite {
352        let mut retained = retained_other;
353        retained.extend(latest_lifecycle);
354        retained.extend(latest_commands.into_values());
355        retained.extend(latest_stop_requests.into_values());
356        retained.sort_by_key(|(sequence, _)| *sequence);
357        journal.replace(retained.into_iter().map(|(_, record)| record))?;
358    }
359
360    if needs_rewrite || tail_repaired {
361        tracing::info!(
362            path = %path.display(),
363            original_bytes,
364            compacted_bytes = std::fs::metadata(path)?.len(),
365            "Compacted Session History control journal"
366        );
367    }
368    Ok(())
369}
370
371fn sync_directory(path: &Path) -> anyhow::Result<()> {
372    File::open(path)
373        .with_context(|| format!("opening directory {} for sync", path.display()))?
374        .sync_all()
375        .with_context(|| format!("syncing directory {}", path.display()))
376}
377
378#[cfg(test)]
379mod tests {
380    use std::{
381        fs::{self, OpenOptions},
382        io::Write as _,
383        sync::atomic::{AtomicU64, Ordering},
384        time::{SystemTime, UNIX_EPOCH},
385    };
386
387    use serde_json::json;
388
389    use super::*;
390
391    static NEXT_ROOT: AtomicU64 = AtomicU64::new(0);
392
393    fn root(label: &str) -> PathBuf {
394        let path = std::env::temp_dir().join(format!(
395            "kcode-session-control-state-{label}-{}-{}-{}",
396            std::process::id(),
397            SystemTime::now()
398                .duration_since(UNIX_EPOCH)
399                .unwrap()
400                .as_nanos(),
401            NEXT_ROOT.fetch_add(1, Ordering::Relaxed),
402        ));
403        fs::create_dir(&path).unwrap();
404        path
405    }
406
407    fn lifecycle(id: &str, version: i64, state: Value) -> SessionRecord {
408        SessionRecord {
409            id: id.into(),
410            phase: "active".into(),
411            started_at: "2026-08-02T00:00:00Z".into(),
412            updated_at: format!("2026-08-02T00:00:0{version}Z"),
413            state,
414            provenance_id: None,
415            version,
416            last_user_message_at: None,
417            ended_at: None,
418            ingress_failure_count: 0,
419            ingress_failures: json!([]),
420            ingress_next_attempt_at: None,
421            summary: false,
422        }
423    }
424
425    fn command(id: &str, status: &str, sequence: i64) -> SessionCommand {
426        SessionCommand {
427            id: id.into(),
428            conversation_id: "session-1".into(),
429            sequence,
430            kind: "message".into(),
431            payload: json!({"text":"hello"}),
432            status: status.into(),
433            cancel_requested: false,
434            outcome: None,
435            created_at: "2026-08-02T00:00:00Z".into(),
436            processing_started_at: None,
437            completed_at: None,
438            idempotency_id: format!("command-{id}"),
439        }
440    }
441
442    fn stop(id: &str, status: &str) -> SessionStopRequest {
443        SessionStopRequest {
444            id: id.into(),
445            session_id: "session-1".into(),
446            scope: "turn".into(),
447            status: status.into(),
448            outcome: None,
449            requested_at: "2026-08-02T00:00:00Z".into(),
450            completed_at: None,
451            idempotency_id: format!("stop-{id}"),
452        }
453    }
454
455    #[test]
456    fn opening_modes_preserve_create_and_absence_distinctions() {
457        let root = root("open-modes");
458        assert!(
459            SessionControl::open(&root, "missing", OpenMode::ExistingOnly)
460                .unwrap()
461                .is_none()
462        );
463
464        let created = SessionControl::open(&root, "new", OpenMode::CreateNew)
465            .unwrap()
466            .unwrap();
467        assert!(control_path(&root, "new").is_file());
468        assert!(SessionControl::open(&root, "new", OpenMode::CreateNew).is_err());
469        drop(created);
470
471        let opened = SessionControl::open(&root, "new", OpenMode::OpenOrCreate)
472            .unwrap()
473            .unwrap();
474        drop(opened);
475        let created_on_absence = SessionControl::open(&root, "other", OpenMode::OpenOrCreate)
476            .unwrap()
477            .unwrap();
478        assert!(control_path(&root, "other").is_file());
479        drop(created_on_absence);
480        fs::remove_dir_all(root).unwrap();
481    }
482
483    #[test]
484    fn typed_append_projects_recursive_lifecycle_state_and_latest_values() {
485        let root = root("typed-projection");
486        let mut control = SessionControl::open(&root, "session-1", OpenMode::CreateNew)
487            .unwrap()
488            .unwrap();
489        let persisted = control
490            .append(
491                "t1",
492                ControlUpdate::Lifecycle(lifecycle(
493                    "session-1",
494                    1,
495                    json!({
496                        "sessionType":"conversation",
497                        "chatendText":"discard",
498                        "commitReceipt":null,
499                        "historyIngress":{
500                            "completed":true,
501                            "commitReceipt":{"sessionObjectId":"A1234567"},
502                            "boxes":{"1":{"text":"discard"}},
503                            "chatendText":"discard"
504                        }
505                    }),
506                )),
507            )
508            .unwrap();
509        let ControlUpdate::Lifecycle(persisted) = persisted else {
510            panic!("append changed update kind");
511        };
512        assert_eq!(persisted.state["sessionType"], "conversation");
513        assert!(persisted.state.get("chatendText").is_none());
514        assert!(persisted.state.get("commitReceipt").is_none());
515        assert_eq!(
516            persisted.state["historyIngress"]["commitReceipt"]["sessionObjectId"],
517            "A1234567"
518        );
519        assert!(
520            persisted.state["historyIngress"]
521                .get("chatendText")
522                .is_none()
523        );
524        assert!(persisted.state["historyIngress"].get("boxes").is_none());
525
526        control
527            .append(
528                "t2",
529                ControlUpdate::Lifecycle(lifecycle(
530                    "session-1",
531                    2,
532                    json!({"sessionType":"conversation","pendingTurn":true}),
533                )),
534            )
535            .unwrap();
536        control
537            .append("t3", ControlUpdate::Command(command("a", "pending", 1)))
538            .unwrap();
539        control
540            .append("t4", ControlUpdate::Command(command("a", "complete", 1)))
541            .unwrap();
542        control
543            .append("t5", ControlUpdate::Command(command("b", "pending", 2)))
544            .unwrap();
545        control
546            .append("t6", ControlUpdate::StopRequest(stop("s", "pending")))
547            .unwrap();
548        control
549            .append("t7", ControlUpdate::StopRequest(stop("s", "complete")))
550            .unwrap();
551
552        let projection = control.projection();
553        assert_eq!(projection.lifecycle.unwrap().version, 2);
554        assert_eq!(projection.commands.len(), 2);
555        assert_eq!(projection.commands["a"].status, "complete");
556        assert_eq!(projection.commands["b"].status, "pending");
557        assert_eq!(projection.stop_requests.len(), 1);
558        assert_eq!(projection.stop_requests["s"].status, "complete");
559        drop(control);
560
561        let reopened = SessionControl::open(&root, "session-1", OpenMode::ExistingOnly)
562            .unwrap()
563            .unwrap();
564        assert_eq!(reopened.projection().lifecycle.unwrap().version, 2);
565        drop(reopened);
566        fs::remove_dir_all(root).unwrap();
567    }
568
569    #[test]
570    fn startup_compaction_retains_unknown_kinds_and_survivor_order() {
571        let root = root("compaction-order");
572        let path = control_path(&root, "session-1");
573        let mut journal = Journal::create(path.clone()).unwrap();
574        journal
575            .append("unknown-first", "t0", json!({"value":0}))
576            .unwrap();
577        journal
578            .append(
579                LIFECYCLE_SIDEBAND,
580                "t1",
581                serde_json::to_value(lifecycle(
582                    "session-1",
583                    1,
584                    json!({"sessionType":"conversation","chatendText":"old"}),
585                ))
586                .unwrap(),
587            )
588            .unwrap();
589        journal
590            .append(
591                COMMAND_SIDEBAND,
592                "t2",
593                serde_json::to_value(command("a", "pending", 1)).unwrap(),
594            )
595            .unwrap();
596        journal
597            .append("unknown-middle", "t3", json!({"value":3}))
598            .unwrap();
599        journal
600            .append(
601                LIFECYCLE_SIDEBAND,
602                "t4",
603                serde_json::to_value(lifecycle(
604                    "session-1",
605                    2,
606                    json!({
607                        "sessionType":"conversation",
608                        "pendingTurn":true,
609                        "historyIngress":{"chatendText":"discard","completed":true}
610                    }),
611                ))
612                .unwrap(),
613            )
614            .unwrap();
615        journal
616            .append(
617                STOP_SIDEBAND,
618                "t5",
619                serde_json::to_value(stop("s", "pending")).unwrap(),
620            )
621            .unwrap();
622        journal
623            .append(
624                COMMAND_SIDEBAND,
625                "t6",
626                serde_json::to_value(command("a", "complete", 1)).unwrap(),
627            )
628            .unwrap();
629        journal
630            .append("unknown-last", "t7", json!({"value":7}))
631            .unwrap();
632        journal
633            .append(
634                STOP_SIDEBAND,
635                "t8",
636                serde_json::to_value(stop("s", "complete")).unwrap(),
637            )
638            .unwrap();
639        drop(journal);
640
641        SessionControl::compact_directory(&root).unwrap();
642
643        let compacted = Journal::open(path).unwrap().unwrap();
644        let kinds = compacted
645            .records()
646            .iter()
647            .map(|record| record.kind.as_str())
648            .collect::<Vec<_>>();
649        assert_eq!(
650            kinds,
651            [
652                "unknown-first",
653                "unknown-middle",
654                LIFECYCLE_SIDEBAND,
655                COMMAND_SIDEBAND,
656                "unknown-last",
657                STOP_SIDEBAND,
658            ]
659        );
660        let projected = project_records(compacted.records());
661        assert_eq!(projected.lifecycle.as_ref().unwrap().version, 2);
662        assert_eq!(
663            projected.lifecycle.unwrap().state["historyIngress"]["completed"],
664            true
665        );
666        assert_eq!(projected.commands["a"].status, "complete");
667        assert_eq!(projected.stop_requests["s"].status, "complete");
668        drop(compacted);
669        fs::remove_dir_all(root).unwrap();
670    }
671
672    #[test]
673    fn malformed_typed_records_keep_the_existing_tolerance_and_errors() {
674        let root = root("malformed");
675        let path = control_path(&root, "session-1");
676        let mut journal = Journal::create(path.clone()).unwrap();
677        journal
678            .append(
679                LIFECYCLE_SIDEBAND,
680                "t1",
681                serde_json::to_value(lifecycle("session-1", 1, json!({}))).unwrap(),
682            )
683            .unwrap();
684        journal
685            .append(LIFECYCLE_SIDEBAND, "t2", json!({"not":"a lifecycle"}))
686            .unwrap();
687        journal
688            .append(COMMAND_SIDEBAND, "t3", json!({"id":"partial"}))
689            .unwrap();
690        drop(journal);
691
692        let control = SessionControl::open(&root, "session-1", OpenMode::ExistingOnly)
693            .unwrap()
694            .unwrap();
695        let projection = control.projection();
696        assert!(projection.lifecycle.is_none());
697        assert!(projection.commands.is_empty());
698        drop(control);
699
700        assert!(SessionControl::compact_directory(&root).is_ok());
701
702        let malformed_path = control_path(&root, "missing-id");
703        let mut malformed = Journal::create(malformed_path).unwrap();
704        malformed
705            .append(COMMAND_SIDEBAND, "t1", json!({"status":"pending"}))
706            .unwrap();
707        drop(malformed);
708        assert!(
709            SessionControl::compact_directory(&root)
710                .unwrap_err()
711                .to_string()
712                .contains("session command record has no ID")
713        );
714        fs::remove_dir_all(root).unwrap();
715    }
716
717    #[test]
718    fn opening_repairs_incomplete_tail_but_rejects_complete_corruption() {
719        let root = root("integrity");
720        let path = control_path(&root, "tail");
721        let mut control = SessionControl::open(&root, "tail", OpenMode::CreateNew)
722            .unwrap()
723            .unwrap();
724        control
725            .append(
726                "t1",
727                ControlUpdate::Lifecycle(lifecycle("tail", 1, json!({}))),
728            )
729            .unwrap();
730        drop(control);
731        let complete = fs::read(&path).unwrap();
732        let mut file = OpenOptions::new().append(true).open(&path).unwrap();
733        file.write_all(b"incomplete tail").unwrap();
734        file.sync_all().unwrap();
735        drop(file);
736        let repaired = SessionControl::open(&root, "tail", OpenMode::ExistingOnly)
737            .unwrap()
738            .unwrap();
739        assert_eq!(repaired.projection().lifecycle.unwrap().version, 1);
740        drop(repaired);
741        assert_eq!(fs::read(&path).unwrap(), complete);
742
743        let corrupt_path = control_path(&root, "corrupt");
744        let mut corrupt = SessionControl::open(&root, "corrupt", OpenMode::CreateNew)
745            .unwrap()
746            .unwrap();
747        corrupt
748            .append(
749                "t1",
750                ControlUpdate::Lifecycle(lifecycle("corrupt", 1, json!({}))),
751            )
752            .unwrap();
753        drop(corrupt);
754        let mut bytes = fs::read(&corrupt_path).unwrap();
755        bytes[0] = if bytes[0] == b'0' { b'1' } else { b'0' };
756        fs::write(&corrupt_path, bytes).unwrap();
757        assert!(SessionControl::open(&root, "corrupt", OpenMode::ExistingOnly).is_err());
758        fs::remove_dir_all(root).unwrap();
759    }
760
761    #[test]
762    fn delete_removes_only_the_control_file() {
763        let root = root("delete");
764        let unrelated = root.join("keep.session-log");
765        fs::write(&unrelated, b"log").unwrap();
766        let control = SessionControl::open(&root, "session-1", OpenMode::CreateNew)
767            .unwrap()
768            .unwrap();
769        let path = control_path(&root, "session-1");
770        control.delete().unwrap();
771        assert!(!path.exists());
772        assert_eq!(fs::read(unrelated).unwrap(), b"log");
773        fs::remove_dir_all(root).unwrap();
774    }
775}