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 "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}