1pub mod chatend;
10pub use chatend::Session;
11
12use std::{
13 collections::{BTreeMap, HashMap, HashSet},
14 fs::{File, OpenOptions},
15 io::{BufRead, BufReader, Read, Write},
16 path::{Path as FilePath, PathBuf},
17 sync::{Arc, Mutex, Weak},
18};
19
20use anyhow::{Context as _, ensure};
21use chrono::{DateTime, Duration, Utc};
22use kcode_session_log::{EventPosition, Role, Session as DurableSession, SessionLog, SessionStore};
23use serde::{Deserialize, Serialize};
24use serde_json::{Map, Value, json};
25use sha2::{Digest, Sha256};
26use uuid::Uuid;
27
28const LIFECYCLE_SIDEBAND: &str = "session_lifecycle";
29const COMMAND_SIDEBAND: &str = "session_command";
30const CONTROL_EXTENSION: &str = "session-control";
31const INGRESS_FAILURE_LIMIT: i64 = 5;
32const INGRESS_RETRY_DELAY_SECONDS: i64 = 15;
33const RETAINED_INGRESS_FAILURES: usize = 5;
34
35#[derive(Clone, Debug, Deserialize, Serialize)]
36#[serde(rename_all = "camelCase")]
37struct ControlRecord {
38 kind: String,
39 recorded_at: String,
40 value: Value,
41}
42
43struct ControlJournal {
44 path: PathBuf,
45 records: Vec<ControlRecord>,
46}
47
48impl ControlJournal {
49 fn create(path: PathBuf) -> anyhow::Result<Self> {
50 let file = OpenOptions::new()
51 .create_new(true)
52 .write(true)
53 .open(&path)?;
54 file.sync_all()?;
55 sync_directory(path.parent().unwrap_or_else(|| FilePath::new(".")))?;
56 Ok(Self {
57 path,
58 records: Vec::new(),
59 })
60 }
61
62 fn open(path: PathBuf) -> anyhow::Result<Self> {
63 if !path.exists() {
64 return Self::create(path);
65 }
66 let mut file = OpenOptions::new().read(true).write(true).open(&path)?;
67 let mut bytes = Vec::new();
68 file.read_to_end(&mut bytes)?;
69 let mut records = Vec::new();
70 let mut cursor = 0_usize;
71 while cursor < bytes.len() {
72 let Some(relative_end) = bytes[cursor..].iter().position(|byte| *byte == b'\n') else {
73 file.set_len(cursor as u64)?;
74 file.sync_all()?;
75 break;
76 };
77 let end = cursor + relative_end;
78 let line = &bytes[cursor..end];
79 let separator = line
80 .iter()
81 .position(|byte| *byte == b' ')
82 .context("session-control record has no checksum separator")?;
83 let expected = std::str::from_utf8(&line[..separator])?;
84 let payload = &line[separator + 1..];
85 ensure!(
86 hex_sha256(payload) == expected,
87 "session-control record checksum mismatch"
88 );
89 records.push(serde_json::from_slice(payload)?);
90 cursor = end + 1;
91 }
92 Ok(Self { path, records })
93 }
94
95 fn records(&self) -> &[ControlRecord] {
96 &self.records
97 }
98
99 fn append(
100 &mut self,
101 kind: impl Into<String>,
102 recorded_at: impl Into<String>,
103 value: Value,
104 ) -> anyhow::Result<()> {
105 let record = ControlRecord {
106 kind: kind.into(),
107 recorded_at: recorded_at.into(),
108 value,
109 };
110 let payload = serde_json::to_vec(&record)?;
111 let mut file = OpenOptions::new().append(true).open(&self.path)?;
112 writeln!(
113 file,
114 "{} {}",
115 hex_sha256(&payload),
116 String::from_utf8(payload)?
117 )?;
118 file.sync_all()?;
119 self.records.push(record);
120 Ok(())
121 }
122}
123
124struct SessionJournal {
125 log: DurableSession,
126 control: ControlJournal,
127}
128
129impl SessionJournal {
130 fn create(directory: &FilePath, id: &str, created_at: &str) -> anyhow::Result<Self> {
131 let log = SessionStore::new(directory).create_session(id, created_at)?;
132 let control = ControlJournal::create(control_path(directory, id))?;
133 Ok(Self { log, control })
134 }
135
136 fn open(path: impl AsRef<FilePath>) -> anyhow::Result<Self> {
137 let path = path.as_ref();
138 ensure!(
139 path.extension().and_then(|value| value.to_str()) == Some("session-log"),
140 "{} is not a session-log path",
141 path.display()
142 );
143 let directory = path.parent().unwrap_or_else(|| FilePath::new("."));
144 let id = path
145 .file_stem()
146 .and_then(|value| value.to_str())
147 .context("session-log filename is not valid UTF-8")?;
148 Ok(Self {
149 log: SessionStore::new(directory).open_session(id)?,
150 control: ControlJournal::open(control_path(directory, id))?,
151 })
152 }
153
154 fn list(&self) -> SessionLog {
155 self.log.list()
156 }
157
158 fn records(&self) -> &[ControlRecord] {
159 self.control.records()
160 }
161
162 fn append_control(
163 &mut self,
164 kind: impl Into<String>,
165 recorded_at: impl Into<String>,
166 value: Value,
167 ) -> anyhow::Result<()> {
168 self.control.append(kind, recorded_at, value)
169 }
170
171 fn stage_object(
172 &mut self,
173 media_type: String,
174 file_name: Option<String>,
175 bytes: &[u8],
176 ) -> anyhow::Result<String> {
177 let file_name = file_name.unwrap_or_else(|| "uploaded-object".into());
178 let position =
179 self.log
180 .add_pending_object(file_name.clone(), file_name, media_type, bytes)?;
181 Ok(format!("pending:{}", position.index() + 1))
182 }
183}
184
185fn control_path(directory: &FilePath, id: &str) -> PathBuf {
186 directory.join(format!("{id}.{CONTROL_EXTENSION}"))
187}
188
189fn hex_sha256(bytes: &[u8]) -> String {
190 Sha256::digest(bytes)
191 .iter()
192 .map(|byte| format!("{byte:02x}"))
193 .collect()
194}
195
196#[derive(Clone, Debug)]
197pub struct Config {
198 pub directory: PathBuf,
199 pub completed_list: PathBuf,
200}
201
202#[derive(Clone, Debug)]
203pub struct NewSession {
204 pub kind: chatend::SessionKind,
205 pub created_at: String,
206 pub effective_context_tokens: u64,
207 pub channel: Value,
208}
209
210#[derive(Clone)]
211struct AppState {
212 config: Config,
213 catalog_mutation: Arc<Mutex<()>>,
214 session_mutations: Arc<Mutex<HashMap<String, Weak<Mutex<()>>>>>,
215}
216
217#[derive(Clone)]
218pub struct SessionHistory {
219 state: AppState,
220}
221
222#[derive(Debug)]
223pub struct Error {
224 pub kind: ErrorKind,
225 pub message: String,
226}
227
228#[derive(Clone, Copy, Debug, Eq, PartialEq)]
229pub enum ErrorKind {
230 InvalidInput,
231 NotFound,
232 Conflict,
233 Storage,
234}
235
236impl ErrorKind {
237 pub fn code(self) -> &'static str {
238 match self {
239 Self::InvalidInput => "invalid_request",
240 Self::NotFound => "not_found",
241 Self::Conflict => "state_conflict",
242 Self::Storage => "internal_error",
243 }
244 }
245}
246
247impl std::fmt::Display for Error {
248 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249 formatter.write_str(&self.message)
250 }
251}
252
253impl std::error::Error for Error {}
254
255impl From<ApiError> for Error {
256 fn from(error: ApiError) -> Self {
257 Self {
258 kind: error.kind,
259 message: error.message,
260 }
261 }
262}
263
264#[derive(Debug)]
265struct ApiError {
266 kind: ErrorKind,
267 message: String,
268}
269
270impl ApiError {
271 fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
272 Self {
273 kind,
274 message: message.into(),
275 }
276 }
277
278 fn bad(message: impl Into<String>) -> Self {
279 Self::new(ErrorKind::InvalidInput, message)
280 }
281
282 fn not_found() -> Self {
283 Self::new(ErrorKind::NotFound, "Session not found.")
284 }
285
286 fn conflict(message: impl Into<String>) -> Self {
287 Self::new(ErrorKind::Conflict, message)
288 }
289
290 fn internal(error: impl std::fmt::Display) -> Self {
291 tracing::warn!(error=%error, "Session History request failed");
292 Self::new(
293 ErrorKind::Storage,
294 "An unexpected Session History storage error occurred.",
295 )
296 }
297}
298
299#[derive(Clone, Debug, Deserialize, Serialize)]
300pub struct SessionRecord {
301 pub id: String,
302 pub phase: String,
303 pub started_at: String,
304 pub updated_at: String,
305 pub state: Value,
306 pub provenance_id: Option<String>,
307 pub version: i64,
308 pub last_user_message_at: Option<String>,
309 pub ended_at: Option<String>,
310 pub ingress_failure_count: i64,
311 pub ingress_failures: Value,
312 pub ingress_next_attempt_at: Option<String>,
313 #[serde(default, skip_serializing_if = "is_false")]
314 pub summary: bool,
315}
316
317fn is_false(value: &bool) -> bool {
318 !*value
319}
320
321#[derive(Clone, Debug, Deserialize, Serialize)]
322pub struct RegisterSession {
323 pub id: String,
324 pub started_at: String,
325 pub state: Value,
326}
327
328#[derive(Clone, Debug, Deserialize, Serialize)]
329pub struct StartSession {
330 pub idempotency_id: String,
331 pub started_at: String,
332 pub session_type: String,
333 #[serde(default)]
334 pub duration_minutes: Option<f64>,
335 #[serde(default)]
336 pub custom_prompt: Option<String>,
337}
338
339#[derive(Clone, Debug, Deserialize, Serialize)]
340pub struct NewIngressSession {
341 pub idempotency_id: String,
342 pub started_at: String,
343 pub source_session_type: String,
344 pub kind: chatend::SessionKind,
345 pub effective_context_tokens: u64,
346 pub text: String,
347 #[serde(default)]
348 pub metadata: Value,
349}
350
351#[derive(Clone, Debug, Deserialize, Serialize)]
352pub struct NewCommand {
353 pub idempotency_id: String,
354 pub kind: String,
355 #[serde(default = "empty_object")]
356 pub payload: Value,
357}
358
359fn empty_object() -> Value {
360 json!({})
361}
362
363#[derive(Clone, Debug, Deserialize, Serialize)]
364pub struct CommandOutcome {
365 #[serde(default = "empty_object")]
366 pub outcome: Value,
367}
368
369#[derive(Clone, Debug, Deserialize, Serialize)]
370#[serde(rename_all = "camelCase")]
371pub struct SessionCommand {
372 pub id: String,
373 pub conversation_id: String,
374 pub sequence: i64,
375 pub kind: String,
376 pub payload: Value,
377 pub status: String,
378 pub cancel_requested: bool,
379 pub outcome: Option<Value>,
380 pub created_at: String,
381 pub processing_started_at: Option<String>,
382 pub completed_at: Option<String>,
383 pub idempotency_id: String,
384}
385
386#[derive(Clone, Debug, Deserialize, Serialize)]
387pub struct Checkpoint {
388 pub expected_version: i64,
389 pub state: Value,
390 #[serde(default)]
391 pub user_activity: bool,
392}
393
394#[derive(Clone, Debug, Deserialize, Serialize)]
395pub struct ExpectedVersion {
396 pub expected_version: i64,
397}
398
399#[derive(Clone, Debug, Deserialize, Serialize)]
400pub struct RetryIngress {
401 pub expected_version: i64,
402 pub state: Value,
403}
404
405#[derive(Clone, Debug, Deserialize, Serialize)]
406pub struct StartIngress {
407 pub expected_version: i64,
408 pub provenance_id: String,
409}
410
411#[derive(Clone, Debug, Deserialize, Serialize)]
412pub struct IngressFailure {
413 pub expected_version: i64,
414 pub stage: String,
415 #[serde(default)]
416 pub code: Option<String>,
417 pub message: String,
418 #[serde(default)]
419 pub rounds_used: Option<u64>,
420 #[serde(default)]
421 pub context_tokens: Option<u64>,
422 #[serde(default)]
423 pub context_window_tokens: Option<u64>,
424}
425
426#[derive(Clone, Debug, Deserialize, Serialize)]
427pub struct RecordCompletion {
428 pub session_object_id: String,
429 #[serde(default)]
430 pub commit_receipt: Option<CompletionReceipt>,
431 #[serde(default)]
432 pub session_id: Option<String>,
433 #[serde(default)]
434 pub session_type: Option<String>,
435 #[serde(default)]
436 pub created_at: Option<String>,
437}
438
439#[derive(Clone, Debug, Deserialize, Serialize)]
440#[serde(rename_all = "camelCase")]
441pub struct CompletionReceipt {
442 #[serde(default)]
443 pub transaction_id: Option<String>,
444 pub session_object_id: String,
445 #[serde(default)]
446 pub session_id: Option<String>,
447 #[serde(default)]
448 pub session_type: Option<String>,
449 #[serde(default)]
450 pub created_at: Option<String>,
451 #[serde(default)]
452 pub committed_at: Option<String>,
453 #[serde(default)]
454 pub ingress_source: Option<Value>,
455 #[serde(default)]
456 pub node_ids: BTreeMap<String, String>,
457 #[serde(default)]
458 pub object_ids: BTreeMap<String, String>,
459}
460
461#[derive(Clone, Debug, Eq, PartialEq)]
462pub struct Created<T> {
463 pub value: T,
464 pub created: bool,
465}
466
467#[derive(Clone, Debug)]
468pub struct NewObject {
469 pub file_name: Option<String>,
470 pub media_type: String,
471 pub bytes: Vec<u8>,
472}
473
474#[derive(Clone, Debug)]
475pub struct StoredObject {
476 pub file_name: String,
477 pub media_type: String,
478 pub bytes: Vec<u8>,
479}
480
481impl SessionHistory {
482 pub fn open(config: Config) -> anyhow::Result<Self> {
483 create_private_directory(&config.directory)?;
484 compact_control_journals(&config.directory)?;
485 if let Some(parent) = config
486 .completed_list
487 .parent()
488 .filter(|path| !path.as_os_str().is_empty())
489 {
490 create_private_directory(parent)?;
491 }
492 if !config.completed_list.exists() {
493 let file = OpenOptions::new()
494 .create_new(true)
495 .write(true)
496 .open(&config.completed_list)
497 .with_context(|| format!("creating {}", config.completed_list.display()))?;
498 file.sync_all()?;
499 sync_directory(
500 config
501 .completed_list
502 .parent()
503 .filter(|path| !path.as_os_str().is_empty())
504 .unwrap_or_else(|| FilePath::new(".")),
505 )?;
506 }
507 Ok(Self {
508 state: AppState {
509 config,
510 catalog_mutation: Arc::new(Mutex::new(())),
511 session_mutations: Arc::new(Mutex::new(HashMap::new())),
512 },
513 })
514 }
515
516 pub fn health(&self) -> Result<(), Error> {
517 read_completed_ids(&self.state.config.completed_list).map_err(ApiError::internal)?;
518 Ok(())
519 }
520
521 pub fn create_session(&self, input: NewSession) -> anyhow::Result<Session> {
522 let metadata = chatend::SessionMetadata {
523 session_id: Uuid::new_v4().to_string(),
524 kind: input.kind,
525 created_at: input.created_at,
526 effective_context_tokens: input.effective_context_tokens,
527 channel: input.channel,
528 };
529 Session::create(
530 self.state
531 .config
532 .directory
533 .join(format!("{}.session-log", metadata.session_id)),
534 metadata,
535 )
536 }
537
538 pub fn open_session(&self, metadata: chatend::SessionMetadata) -> anyhow::Result<Session> {
539 validate_session_id(&metadata.session_id)
540 .map_err(|error| anyhow::anyhow!(error.message))?;
541 Session::open_with_metadata(
542 self.state
543 .config
544 .directory
545 .join(format!("{}.session-log", metadata.session_id)),
546 metadata,
547 )
548 }
549
550 pub async fn register(&self, input: RegisterSession) -> Result<SessionRecord, Error> {
551 create_session(self.state.clone(), input)
552 .await
553 .map_err(Into::into)
554 }
555
556 pub async fn start(&self, input: StartSession) -> Result<Created<SessionRecord>, Error> {
557 let (created, record) = start_managed_session(self.state.clone(), input).await?;
558 Ok(Created {
559 value: record,
560 created,
561 })
562 }
563
564 pub async fn enqueue_ingress(
565 &self,
566 input: NewIngressSession,
567 ) -> Result<Created<SessionRecord>, Error> {
568 let (created, record) = enqueue_ingress_session(self.state.clone(), input).await?;
569 Ok(Created {
570 value: record,
571 created,
572 })
573 }
574
575 pub async fn list(&self) -> Result<Vec<SessionRecord>, Error> {
576 let value = list_session_summaries(self.state.clone()).await?;
577 serde_json::from_value(
578 value
579 .get("conversations")
580 .cloned()
581 .unwrap_or_else(|| json!([])),
582 )
583 .map_err(ApiError::internal)
584 .map_err(Into::into)
585 }
586
587 pub async fn get(&self, id: &str) -> Result<SessionRecord, Error> {
588 get_session(self.state.clone(), id.to_owned())
589 .await
590 .map_err(Into::into)
591 }
592
593 pub async fn enqueue(
594 &self,
595 id: &str,
596 input: NewCommand,
597 ) -> Result<Created<SessionCommand>, Error> {
598 let (created, command) =
599 queue_session_command(self.state.clone(), id.to_owned(), input).await?;
600 Ok(Created {
601 value: command,
602 created,
603 })
604 }
605
606 pub async fn command_heads(&self) -> Result<Vec<SessionCommand>, Error> {
607 let value = list_command_heads(self.state.clone()).await?;
608 serde_json::from_value(value.get("commands").cloned().unwrap_or_else(|| json!([])))
609 .map_err(ApiError::internal)
610 .map_err(Into::into)
611 }
612
613 pub async fn claim_command(&self, id: &str) -> Result<SessionCommand, Error> {
614 claim_command(self.state.clone(), id.to_owned())
615 .await
616 .map_err(Into::into)
617 }
618
619 pub async fn complete_command(
620 &self,
621 id: &str,
622 outcome: CommandOutcome,
623 ) -> Result<SessionCommand, Error> {
624 complete_command(self.state.clone(), id.to_owned(), outcome)
625 .await
626 .map_err(Into::into)
627 }
628
629 pub async fn stop(&self, id: &str) -> Result<SessionRecord, Error> {
630 let current = fetch_active(&self.state, id)?;
631 let record = transition(
632 self.state.clone(),
633 id,
634 current.version,
635 "ingress_pending",
636 None,
637 )
638 .await?;
639 Ok(record)
640 }
641
642 pub async fn stage_object(&self, id: &str, object: NewObject) -> Result<String, Error> {
643 stage_session_object(&self.state, id, object).map_err(Into::into)
644 }
645
646 pub fn object(&self, id: &str, pending_id: &str) -> Result<StoredObject, Error> {
647 get_session_object(&self.state, id, pending_id).map_err(Into::into)
648 }
649
650 pub async fn checkpoint(&self, id: &str, input: Checkpoint) -> Result<SessionRecord, Error> {
651 let record = checkpoint(
652 self.state.clone(),
653 id,
654 input.expected_version,
655 input.state,
656 input.user_activity,
657 )
658 .await?;
659 Ok(record)
660 }
661
662 pub async fn request_ingress(
663 &self,
664 id: &str,
665 input: Checkpoint,
666 ) -> Result<SessionRecord, Error> {
667 let record =
668 transition_with_checkpoint(self.state.clone(), id, input, "ingress_pending").await?;
669 Ok(record)
670 }
671
672 pub async fn start_ingress(
673 &self,
674 id: &str,
675 input: StartIngress,
676 ) -> Result<SessionRecord, Error> {
677 let record = transition(
678 self.state.clone(),
679 id,
680 input.expected_version,
681 "ingress_in_progress",
682 Some(input.provenance_id),
683 )
684 .await?;
685 Ok(record)
686 }
687
688 pub async fn complete_ingress(
689 &self,
690 id: &str,
691 input: ExpectedVersion,
692 ) -> Result<SessionRecord, Error> {
693 let current = fetch_active(&self.state, id)?;
694 let record = complete_session(
695 self.state.clone(),
696 id,
697 input.expected_version,
698 current.state,
699 )
700 .await?;
701 Ok(record)
702 }
703
704 pub async fn fail_ingress(
705 &self,
706 id: &str,
707 input: IngressFailure,
708 ) -> Result<SessionRecord, Error> {
709 let record = record_ingress_failure(self.state.clone(), id, input).await?;
710 Ok(record)
711 }
712
713 pub async fn retry_ingress(
714 &self,
715 id: &str,
716 input: RetryIngress,
717 ) -> Result<SessionRecord, Error> {
718 retry_ingress(self.state.clone(), id.to_owned(), input)
719 .await
720 .map_err(Into::into)
721 }
722
723 pub async fn release_interrupted_ingress(&self) -> Result<Vec<String>, Error> {
724 let value = release_ingress_repairs(self.state.clone()).await?;
725 serde_json::from_value(value.get("released").cloned().unwrap_or_else(|| json!([])))
726 .map_err(ApiError::internal)
727 .map_err(Into::into)
728 }
729
730 pub async fn complete(&self, id: &str, input: Checkpoint) -> Result<SessionRecord, Error> {
731 let record =
732 complete_session(self.state.clone(), id, input.expected_version, input.state).await?;
733 Ok(record)
734 }
735
736 pub async fn record_completion(&self, input: RecordCompletion) -> Result<(), Error> {
737 record_completed_session(self.state.clone(), input).await?;
738 Ok(())
739 }
740}
741
742async fn record_completed_session(
743 state: AppState,
744 input: RecordCompletion,
745) -> Result<Value, ApiError> {
746 let session_guard = input
747 .session_id
748 .as_deref()
749 .map(|id| session_mutation(&state, id))
750 .transpose()?;
751 let _session_guard = session_guard
752 .as_ref()
753 .map(|guard| guard.lock().map_err(ApiError::internal))
754 .transpose()?;
755 let _catalog_guard = state.catalog_mutation.lock().map_err(ApiError::internal)?;
756 let mut receipt = input.commit_receipt.unwrap_or(CompletionReceipt {
757 transaction_id: None,
758 session_object_id: input.session_object_id.clone(),
759 session_id: None,
760 session_type: None,
761 created_at: None,
762 committed_at: None,
763 ingress_source: None,
764 node_ids: BTreeMap::new(),
765 object_ids: BTreeMap::new(),
766 });
767 if receipt.session_object_id != input.session_object_id {
768 return Err(ApiError::conflict(
769 "completion receipt and requested session object differ",
770 ));
771 }
772 receipt.session_id = receipt.session_id.or(input.session_id.clone());
773 receipt.session_type = receipt.session_type.or(input.session_type);
774 receipt.created_at = receipt.created_at.or(input.created_at);
775 receipt.committed_at.get_or_insert_with(now);
776 append_completion_receipt(&state.config.completed_list, &receipt)
777 .map_err(ApiError::internal)?;
778 if let Some(id) = input.session_id {
779 validate_session_id(&id)?;
780 let path = state.config.directory.join(format!("{id}.session-log"));
781 if path.exists() {
782 SessionJournal::open(&path)
783 .map_err(ApiError::internal)?
784 .log
785 .delete_committed()
786 .map_err(ApiError::internal)?;
787 let control = control_path(&state.config.directory, &id);
788 if control.exists() {
789 std::fs::remove_file(&control)
790 .with_context(|| format!("removing {}", control.display()))
791 .map_err(ApiError::internal)?;
792 sync_directory(&state.config.directory).map_err(ApiError::internal)?;
793 }
794 }
795 }
796 Ok(json!({
797 "sessionObjectId":input.session_object_id,
798 "recorded":true
799 }))
800}
801
802async fn create_session(
803 state: AppState,
804 input: RegisterSession,
805) -> Result<SessionRecord, ApiError> {
806 validate_started_at(&input.started_at)?;
807 validate_session_id(&input.id)?;
808 let path = state
809 .config
810 .directory
811 .join(format!("{}.session-log", input.id));
812 let journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
813 let id = journal.list().header.session_id;
814 if id != input.id {
815 return Err(ApiError::bad(
816 "registered session ID does not match its durable session log",
817 ));
818 }
819 drop(journal);
820 let session_guard = session_mutation(&state, &id)?;
821 let _guard = session_guard.lock().map_err(ApiError::internal)?;
822 let mut journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
823 if latest_lifecycle(&journal).is_some() {
824 return Err(ApiError::conflict("Session is already registered."));
825 }
826 let record = SessionRecord {
827 id,
828 phase: "active".into(),
829 started_at: input.started_at.clone(),
830 updated_at: input.started_at,
831 state: control_state(&input.state),
832 provenance_id: None,
833 version: 1,
834 last_user_message_at: None,
835 ended_at: None,
836 ingress_failure_count: 0,
837 ingress_failures: json!([]),
838 ingress_next_attempt_at: None,
839 summary: false,
840 };
841 append_lifecycle(&mut journal, &record)?;
842 Ok(materialize(record, &journal))
843}
844
845async fn start_managed_session(
846 state: AppState,
847 input: StartSession,
848) -> Result<(bool, SessionRecord), ApiError> {
849 validate_started_at(&input.started_at)?;
850 validate_idempotency(&input.idempotency_id)?;
851 let _catalog_guard = state.catalog_mutation.lock().map_err(ApiError::internal)?;
852 for path in journal_paths(&state.config.directory)? {
853 let journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
854 if let Some(record) = latest_lifecycle(&journal)
855 && record
856 .state
857 .get("startIdempotencyId")
858 .and_then(Value::as_str)
859 == Some(&input.idempotency_id)
860 {
861 return Ok((false, materialize(record, &journal)));
862 }
863 }
864 let id = Uuid::new_v4().to_string();
865 let mut journal = SessionJournal::create(&state.config.directory, &id, &input.started_at)
866 .map_err(ApiError::internal)?;
867 let mut session_state = json!({
868 "stateVersion":3,
869 "sessionId":id,
870 "sessionType":input.session_type,
871 "startedAt":input.started_at,
872 "startIdempotencyId":input.idempotency_id,
873 "orchestration":{"owner":"backend","status":"idle"},
874 });
875 if input.session_type == "free-time" {
876 session_state["selfTimeIntent"] = json!({
877 "requestedAt":input.started_at,
878 "durationMinutes":input.duration_minutes,
879 "customPrompt":input.custom_prompt.unwrap_or_default(),
880 });
881 }
882 let record = SessionRecord {
883 id,
884 phase: "active".into(),
885 started_at: input.started_at.clone(),
886 updated_at: input.started_at,
887 state: session_state,
888 provenance_id: None,
889 version: 1,
890 last_user_message_at: None,
891 ended_at: None,
892 ingress_failure_count: 0,
893 ingress_failures: json!([]),
894 ingress_next_attempt_at: None,
895 summary: false,
896 };
897 append_lifecycle(&mut journal, &record)?;
898 Ok((true, materialize(record, &journal)))
899}
900
901async fn enqueue_ingress_session(
902 state: AppState,
903 input: NewIngressSession,
904) -> Result<(bool, SessionRecord), ApiError> {
905 validate_started_at(&input.started_at)?;
906 validate_idempotency(&input.idempotency_id)?;
907 if input.source_session_type.trim().is_empty() {
908 return Err(ApiError::bad("Source session type must not be empty."));
909 }
910 if input.text.trim().is_empty() {
911 return Err(ApiError::bad("Ingress source text must not be empty."));
912 }
913 let _catalog_guard = state.catalog_mutation.lock().map_err(ApiError::internal)?;
914 for path in journal_paths(&state.config.directory)? {
915 let journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
916 if let Some(record) = latest_lifecycle(&journal)
917 && ingress_idempotency_id(record.state.get("ingressSource"))
918 == Some(input.idempotency_id.as_str())
919 {
920 return Ok((false, materialize(record, &journal)));
921 }
922 }
923 for receipt in
924 read_completion_receipts(&state.config.completed_list).map_err(ApiError::internal)?
925 {
926 if ingress_idempotency_id(receipt.ingress_source.as_ref())
927 == Some(input.idempotency_id.as_str())
928 {
929 return Ok((false, completed_session_record(receipt, false)));
930 }
931 }
932
933 let id = Uuid::new_v4().to_string();
934 let path = state.config.directory.join(format!("{id}.session-log"));
935 let mut source = Session::create(
936 &path,
937 chatend::SessionMetadata {
938 session_id: id.clone(),
939 kind: input.kind,
940 created_at: input.started_at.clone(),
941 effective_context_tokens: input.effective_context_tokens,
942 channel: Value::Null,
943 },
944 )
945 .map_err(ApiError::internal)?;
946 source
947 .create_box(
948 input.started_at.clone(),
949 "Ingress source",
950 chatend::BoxOwner::User,
951 chatend::BoxContent {
952 text: input.text.trim().to_owned(),
953 objects: Vec::new(),
954 metadata: input.metadata.clone(),
955 },
956 )
957 .map_err(ApiError::internal)?;
958 drop(source);
959
960 let ingress_source = json!({
961 "idempotencyId":input.idempotency_id,
962 "metadata":input.metadata,
963 });
964 let mut journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
965 let record = SessionRecord {
966 id: id.clone(),
967 phase: "ingress_pending".into(),
968 started_at: input.started_at.clone(),
969 updated_at: input.started_at.clone(),
970 state: json!({
971 "stateVersion":3,
972 "sessionId":id,
973 "sessionType":input.source_session_type,
974 "startedAt":input.started_at,
975 "ingressSource":ingress_source,
976 }),
977 provenance_id: None,
978 version: 1,
979 last_user_message_at: None,
980 ended_at: None,
981 ingress_failure_count: 0,
982 ingress_failures: json!([]),
983 ingress_next_attempt_at: None,
984 summary: false,
985 };
986 append_lifecycle(&mut journal, &record)?;
987 Ok((true, materialize(record, &journal)))
988}
989
990fn ingress_idempotency_id(source: Option<&Value>) -> Option<&str> {
991 source?.get("idempotencyId").and_then(Value::as_str)
992}
993
994async fn list_session_summaries(state: AppState) -> Result<Value, ApiError> {
995 let mut sessions = Vec::new();
996 for path in journal_paths(&state.config.directory)? {
997 let journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
998 if let Some(mut record) = latest_lifecycle(&journal) {
999 record.summary = true;
1000 record.state = summary_state(&record.state, &journal);
1001 sessions.push(record);
1002 }
1003 }
1004 for receipt in
1005 read_completion_receipts(&state.config.completed_list).map_err(ApiError::internal)?
1006 {
1007 sessions.push(completed_session_record(receipt, true));
1008 }
1009 sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
1010 Ok(json!({"conversations":sessions}))
1011}
1012
1013async fn get_session(state: AppState, id: String) -> Result<SessionRecord, ApiError> {
1014 if let Some(receipt) = read_completion_receipts(&state.config.completed_list)
1015 .map_err(ApiError::internal)?
1016 .into_iter()
1017 .find(|receipt| receipt.session_object_id == id)
1018 {
1019 return Ok(completed_session_record(receipt, false));
1020 }
1021 let journal = open_by_id(&state, &id)?;
1022 let record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1023 Ok(materialize(record, &journal))
1024}
1025
1026fn completed_session_record(receipt: CompletionReceipt, summary: bool) -> SessionRecord {
1027 let object_id = receipt.session_object_id.clone();
1028 let started_at = receipt.created_at.clone().unwrap_or_default();
1029 let updated_at = receipt.committed_at.clone().unwrap_or_default();
1030 SessionRecord {
1031 id: object_id.clone(),
1032 phase: "complete".into(),
1033 started_at,
1034 updated_at,
1035 state: json!({
1036 "sessionObjectId":object_id,
1037 "sessionId":receipt.session_id.clone(),
1038 "sessionType":receipt.session_type.clone(),
1039 "ingressSource":receipt.ingress_source.clone(),
1040 "commitReceipt":receipt,
1041 }),
1042 provenance_id: None,
1043 version: 1,
1044 last_user_message_at: None,
1045 ended_at: None,
1046 ingress_failure_count: 0,
1047 ingress_failures: json!([]),
1048 ingress_next_attempt_at: None,
1049 summary,
1050 }
1051}
1052
1053fn get_session_object(
1054 state: &AppState,
1055 id: &str,
1056 pending_id: &str,
1057) -> Result<StoredObject, ApiError> {
1058 let number = pending_id
1059 .strip_prefix("pending:")
1060 .and_then(|value| value.parse::<u64>().ok())
1061 .filter(|value| *value > 0)
1062 .ok_or_else(|| ApiError::bad("Object ID must have the form pending:N."))?;
1063 let journal = open_by_id(state, id)?;
1064 let object = journal
1065 .log
1066 .read_pending_object(EventPosition(number - 1))
1067 .map_err(|error| {
1068 tracing::warn!(session_id=id, pending_id, %error, "pending session object is unavailable");
1069 ApiError::not_found()
1070 })?;
1071 let media_type = if object.media_type.trim().is_empty()
1072 || object
1073 .media_type
1074 .chars()
1075 .any(|character| character.is_control() || character.is_whitespace())
1076 || !object.media_type.contains('/')
1077 {
1078 "application/octet-stream"
1079 } else {
1080 &object.media_type
1081 };
1082 let mut file_name = object
1083 .file_name
1084 .chars()
1085 .map(|character| {
1086 if character.is_ascii_graphic() && !matches!(character, '"' | '\\' | '/' | ';') {
1087 character
1088 } else {
1089 '_'
1090 }
1091 })
1092 .take(255)
1093 .collect::<String>();
1094 if file_name.is_empty() {
1095 file_name = "uploaded-object".into();
1096 }
1097 Ok(StoredObject {
1098 file_name,
1099 media_type: media_type.to_owned(),
1100 bytes: object.bytes,
1101 })
1102}
1103
1104async fn queue_session_command(
1105 state: AppState,
1106 id: String,
1107 input: NewCommand,
1108) -> Result<(bool, SessionCommand), ApiError> {
1109 validate_idempotency(&input.idempotency_id)?;
1110 let session_guard = session_mutation(&state, &id)?;
1111 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1112 let mut journal = open_by_id(&state, &id)?;
1113 let record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1114 if record.phase != "active" {
1115 return Err(ApiError::conflict("Session is no longer active."));
1116 }
1117 let commands = commands(&journal);
1118 if let Some(command) = commands
1119 .values()
1120 .find(|command| command.idempotency_id == input.idempotency_id)
1121 {
1122 return Ok((false, command.clone()));
1123 }
1124 let command = SessionCommand {
1125 id: Uuid::new_v4().to_string(),
1126 conversation_id: id,
1127 sequence: commands
1128 .values()
1129 .map(|command| command.sequence)
1130 .max()
1131 .unwrap_or(0)
1132 + 1,
1133 kind: input.kind,
1134 payload: input.payload,
1135 status: "pending".into(),
1136 cancel_requested: false,
1137 outcome: None,
1138 created_at: now(),
1139 processing_started_at: None,
1140 completed_at: None,
1141 idempotency_id: input.idempotency_id,
1142 };
1143 append_command(&mut journal, &command)?;
1144 Ok((true, command))
1145}
1146
1147fn stage_session_object(state: &AppState, id: &str, object: NewObject) -> Result<String, ApiError> {
1148 let NewObject {
1149 file_name,
1150 media_type,
1151 bytes,
1152 } = object;
1153 let session_guard = session_mutation(state, id)?;
1154 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1155 let mut journal = open_by_id(state, id)?;
1156 let record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1157 if record.phase != "active" {
1158 return Err(ApiError::conflict(
1159 "Objects can only be supplied to an active session.",
1160 ));
1161 }
1162 if commands(&journal)
1163 .values()
1164 .any(|command| matches!(command.status.as_str(), "pending" | "processing"))
1165 {
1166 return Err(ApiError::conflict(
1167 "Objects cannot be supplied while the session is processing a command.",
1168 ));
1169 }
1170 let pending_id = journal
1171 .stage_object(media_type, file_name, &bytes)
1172 .map_err(|error| ApiError::bad(error.to_string()))?;
1173 Ok(pending_id)
1174}
1175
1176async fn list_command_heads(state: AppState) -> Result<Value, ApiError> {
1177 let mut heads = Vec::new();
1178 for path in journal_paths(&state.config.directory)? {
1179 let journal = SessionJournal::open(path).map_err(ApiError::internal)?;
1180 let mut active = commands(&journal)
1181 .into_values()
1182 .filter(|command| matches!(command.status.as_str(), "pending" | "processing"))
1183 .collect::<Vec<_>>();
1184 active.sort_by_key(|command| command.sequence);
1185 if let Some(head) = active.into_iter().next() {
1186 heads.push(head);
1187 }
1188 }
1189 heads.sort_by(|a, b| a.created_at.cmp(&b.created_at));
1190 Ok(json!({"commands":heads}))
1191}
1192
1193async fn claim_command(state: AppState, command_id: String) -> Result<SessionCommand, ApiError> {
1194 mutate_command(&state, &command_id, |command| {
1195 if command.status == "pending" {
1196 command.status = "processing".into();
1197 command.processing_started_at = Some(now());
1198 } else if command.status != "processing" {
1199 return Err(ApiError::conflict("Command is already complete."));
1200 }
1201 Ok(())
1202 })
1203}
1204
1205async fn complete_command(
1206 state: AppState,
1207 command_id: String,
1208 input: CommandOutcome,
1209) -> Result<SessionCommand, ApiError> {
1210 mutate_command(&state, &command_id, |command| {
1211 if command.status == "complete" {
1212 return Ok(());
1213 }
1214 if command.status != "processing" {
1215 return Err(ApiError::conflict("Command was not claimed."));
1216 }
1217 command.status = "complete".into();
1218 command.outcome = Some(input.outcome);
1219 command.completed_at = Some(now());
1220 Ok(())
1221 })
1222}
1223
1224fn mutate_command(
1225 state: &AppState,
1226 command_id: &str,
1227 mutation: impl FnOnce(&mut SessionCommand) -> Result<(), ApiError>,
1228) -> Result<SessionCommand, ApiError> {
1229 let mut target = None;
1230 for path in journal_paths(&state.config.directory)? {
1231 let journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
1232 if let Some(command) = commands(&journal).remove(command_id) {
1233 target = Some((path, command.conversation_id));
1234 break;
1235 }
1236 }
1237 let (path, conversation_id) = target.ok_or_else(ApiError::not_found)?;
1238 let session_guard = session_mutation(state, &conversation_id)?;
1239 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1240 let mut journal = SessionJournal::open(path).map_err(ApiError::internal)?;
1241 let mut command = commands(&journal)
1242 .remove(command_id)
1243 .ok_or_else(ApiError::not_found)?;
1244 mutation(&mut command)?;
1245 append_command(&mut journal, &command)?;
1246 Ok(command)
1247}
1248
1249async fn checkpoint(
1250 state: AppState,
1251 id: &str,
1252 expected_version: i64,
1253 new_state: Value,
1254 user_activity: bool,
1255) -> Result<SessionRecord, ApiError> {
1256 let session_guard = session_mutation(&state, id)?;
1257 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1258 let mut journal = open_by_id(&state, id)?;
1259 let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1260 require_version(&record, expected_version)?;
1261 record.state = control_state(&new_state);
1262 record.version += 1;
1263 record.updated_at = now();
1264 if user_activity {
1265 record.last_user_message_at = Some(record.updated_at.clone());
1266 }
1267 append_lifecycle(&mut journal, &record)?;
1268 Ok(materialize(record, &journal))
1269}
1270
1271async fn transition_with_checkpoint(
1272 state: AppState,
1273 id: &str,
1274 input: Checkpoint,
1275 phase: &str,
1276) -> Result<SessionRecord, ApiError> {
1277 let session_guard = session_mutation(&state, id)?;
1278 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1279 let mut journal = open_by_id(&state, id)?;
1280 let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1281 require_version(&record, input.expected_version)?;
1282 record.state = control_state(&input.state);
1283 record.phase = phase.into();
1284 if phase == "ingress_pending" {
1285 record.ingress_next_attempt_at = None;
1286 }
1287 record.version += 1;
1288 record.updated_at = now();
1289 append_lifecycle(&mut journal, &record)?;
1290 Ok(materialize(record, &journal))
1291}
1292
1293async fn transition(
1294 state: AppState,
1295 id: &str,
1296 expected_version: i64,
1297 phase: &str,
1298 provenance_id: Option<String>,
1299) -> Result<SessionRecord, ApiError> {
1300 let session_guard = session_mutation(&state, id)?;
1301 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1302 let mut journal = open_by_id(&state, id)?;
1303 let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1304 require_version(&record, expected_version)?;
1305 record.phase = phase.into();
1306 record.provenance_id = provenance_id;
1307 if phase == "ingress_in_progress" {
1308 record.ingress_next_attempt_at = None;
1309 }
1310 record.version += 1;
1311 record.updated_at = now();
1312 append_lifecycle(&mut journal, &record)?;
1313 Ok(materialize(record, &journal))
1314}
1315
1316async fn record_ingress_failure(
1317 state: AppState,
1318 id: &str,
1319 input: IngressFailure,
1320) -> Result<SessionRecord, ApiError> {
1321 let session_guard = session_mutation(&state, id)?;
1322 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1323 let mut journal = open_by_id(&state, id)?;
1324 let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1325 require_version(&record, input.expected_version)?;
1326 if !matches!(
1327 record.phase.as_str(),
1328 "ingress_pending" | "ingress_in_progress"
1329 ) {
1330 return Err(ApiError::conflict(
1331 "Session History ingress is not in an active attempt.",
1332 ));
1333 }
1334 let attempt = record.ingress_failure_count.saturating_add(1);
1335 let terminal =
1336 input.code.as_deref() == Some("input_too_large") || attempt >= INGRESS_FAILURE_LIMIT;
1337 let mut failures = record
1338 .ingress_failures
1339 .as_array()
1340 .cloned()
1341 .unwrap_or_default();
1342 failures.push(json!({
1343 "attempt":attempt,
1344 "at":now(),
1345 "stage":input.stage,
1346 "code":input.code,
1347 "message":input.message,
1348 "roundsUsed":input.rounds_used,
1349 "contextTokens":input.context_tokens,
1350 "contextWindowTokens":input.context_window_tokens,
1351 }));
1352 if failures.len() > RETAINED_INGRESS_FAILURES {
1353 failures.drain(..failures.len() - RETAINED_INGRESS_FAILURES);
1354 }
1355 record.ingress_failures = Value::Array(failures);
1356 record.ingress_failure_count = attempt;
1357 record.phase = if terminal {
1358 "ingress_failed".into()
1359 } else {
1360 "ingress_pending".into()
1361 };
1362 let updated_at = now();
1363 record.ingress_next_attempt_at = (!terminal)
1364 .then(|| (Utc::now() + Duration::seconds(INGRESS_RETRY_DELAY_SECONDS)).to_rfc3339());
1365 record.version += 1;
1366 record.updated_at = updated_at;
1367 append_lifecycle(&mut journal, &record)?;
1368 if terminal {
1369 tracing::error!(
1370 session_id = id,
1371 attempt,
1372 stage = %input.stage,
1373 code = input.code.as_deref().unwrap_or("ingress_error"),
1374 terminal_reason = if input.code.as_deref() == Some("input_too_large") {
1375 "non_retryable"
1376 } else {
1377 "retry_limit"
1378 },
1379 "Session History ingress stopped after a terminal failure"
1380 );
1381 }
1382 Ok(materialize(record, &journal))
1383}
1384
1385async fn retry_ingress(
1386 state: AppState,
1387 id: String,
1388 input: RetryIngress,
1389) -> Result<SessionRecord, ApiError> {
1390 let session_guard = session_mutation(&state, &id)?;
1391 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1392 let mut journal = open_by_id(&state, &id)?;
1393 let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1394 require_version(&record, input.expected_version)?;
1395 if record.phase != "ingress_failed" {
1396 return Err(ApiError::conflict(
1397 "Session History ingress is not in the failed state.",
1398 ));
1399 }
1400 record.state = control_state(&input.state);
1401 record.phase = "ingress_pending".into();
1402 record.ingress_failure_count = 0;
1403 record.ingress_next_attempt_at = None;
1404 record.version += 1;
1405 record.updated_at = now();
1406 append_lifecycle(&mut journal, &record)?;
1407 Ok(materialize(record, &journal))
1408}
1409
1410async fn release_ingress_repairs(state: AppState) -> Result<Value, ApiError> {
1411 let mut released = Vec::new();
1412 for path in journal_paths(&state.config.directory)? {
1413 let Some(id) = path
1414 .file_stem()
1415 .and_then(|value| value.to_str())
1416 .map(str::to_owned)
1417 else {
1418 continue;
1419 };
1420 let session_guard = session_mutation(&state, &id)?;
1421 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1422 let mut journal = open_by_id(&state, &id)?;
1423 let Some(mut record) = latest_lifecycle(&journal) else {
1424 continue;
1425 };
1426 if record.phase != "ingress_in_progress" {
1427 continue;
1428 }
1429 record.phase = "ingress_pending".into();
1430 record.ingress_next_attempt_at = None;
1431 if record.ingress_failure_count > INGRESS_FAILURE_LIMIT {
1432 record.ingress_failure_count = 0;
1433 }
1434 if let Some(failures) = record.ingress_failures.as_array_mut()
1435 && failures.len() > RETAINED_INGRESS_FAILURES
1436 {
1437 failures.drain(..failures.len() - RETAINED_INGRESS_FAILURES);
1438 }
1439 record.version += 1;
1440 record.updated_at = now();
1441 append_lifecycle(&mut journal, &record)?;
1442 released.push(id);
1443 }
1444 Ok(json!({"released":released}))
1445}
1446
1447async fn complete_session(
1448 state: AppState,
1449 id: &str,
1450 expected_version: i64,
1451 new_state: Value,
1452) -> Result<SessionRecord, ApiError> {
1453 let session_guard = session_mutation(&state, id)?;
1454 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1455 let journal = open_by_id(&state, id)?;
1456 let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1457 require_version(&record, expected_version)?;
1458 let completion_state = new_state
1459 .get("historyIngress")
1460 .filter(|state| state.get("sessionObjectId").is_some_and(Value::is_string))
1461 .cloned()
1462 .unwrap_or_else(|| new_state.clone());
1463 record.state = control_state(&new_state);
1464 let object_id = completion_state
1465 .get("sessionObjectId")
1466 .and_then(Value::as_str)
1467 .ok_or_else(|| {
1468 ApiError::conflict("completed session has no permanent Kweb session object")
1469 })?
1470 .to_owned();
1471 let _catalog_guard = state.catalog_mutation.lock().map_err(ApiError::internal)?;
1472 let mut receipt = completion_state
1473 .get("commitReceipt")
1474 .filter(|receipt| !receipt.is_null())
1475 .cloned()
1476 .map(serde_json::from_value::<CompletionReceipt>)
1477 .transpose()
1478 .map_err(|error| ApiError::conflict(format!("invalid session commit receipt: {error}")))?
1479 .unwrap_or(CompletionReceipt {
1480 transaction_id: None,
1481 session_object_id: object_id.clone(),
1482 session_id: None,
1483 session_type: None,
1484 created_at: None,
1485 committed_at: None,
1486 ingress_source: None,
1487 node_ids: BTreeMap::new(),
1488 object_ids: BTreeMap::new(),
1489 });
1490 if receipt.session_object_id != object_id {
1491 return Err(ApiError::conflict(
1492 "session commit receipt names a different archive object",
1493 ));
1494 }
1495 let completed_at = now();
1496 receipt.session_id = Some(record.id.clone());
1497 receipt.session_type = record
1498 .state
1499 .get("sessionType")
1500 .and_then(Value::as_str)
1501 .map(str::to_owned);
1502 receipt.created_at = Some(record.started_at.clone());
1503 receipt.committed_at = Some(completed_at.clone());
1504 receipt.ingress_source = record.state.get("ingressSource").cloned();
1505 append_completion_receipt(&state.config.completed_list, &receipt)
1506 .map_err(ApiError::internal)?;
1507 record.phase = "complete".into();
1508 record.version += 1;
1509 record.updated_at = completed_at;
1510 record.ended_at = Some(record.updated_at.clone());
1511 record.state["sessionObjectId"] = json!(object_id);
1512 record.state["commitReceipt"] = completion_state
1513 .get("commitReceipt")
1514 .cloned()
1515 .unwrap_or(Value::Null);
1516 let output = materialize(record, &journal);
1517 let control_path = control_path(&state.config.directory, id);
1518 journal.log.delete_committed().map_err(ApiError::internal)?;
1519 if control_path.exists() {
1520 std::fs::remove_file(&control_path)
1521 .with_context(|| format!("removing {}", control_path.display()))
1522 .map_err(ApiError::internal)?;
1523 sync_directory(&state.config.directory).map_err(ApiError::internal)?;
1524 }
1525 Ok(output)
1526}
1527
1528fn fetch_active(state: &AppState, id: &str) -> Result<SessionRecord, ApiError> {
1529 let journal = open_by_id(state, id)?;
1530 latest_lifecycle(&journal).ok_or_else(ApiError::not_found)
1531}
1532
1533fn session_mutation(state: &AppState, id: &str) -> Result<Arc<Mutex<()>>, ApiError> {
1534 let mut sessions = state.session_mutations.lock().map_err(ApiError::internal)?;
1535 sessions.retain(|_, lock| lock.strong_count() > 0);
1536 if let Some(lock) = sessions.get(id).and_then(Weak::upgrade) {
1537 return Ok(lock);
1538 }
1539 let lock = Arc::new(Mutex::new(()));
1540 sessions.insert(id.to_owned(), Arc::downgrade(&lock));
1541 Ok(lock)
1542}
1543
1544fn open_by_id(state: &AppState, id: &str) -> Result<SessionJournal, ApiError> {
1545 validate_session_id(id)?;
1546 let path = state.config.directory.join(format!("{id}.session-log"));
1547 SessionJournal::open(&path).map_err(|error| {
1548 if !path.exists() {
1549 ApiError::not_found()
1550 } else {
1551 ApiError::internal(error)
1552 }
1553 })
1554}
1555
1556fn latest_lifecycle(journal: &SessionJournal) -> Option<SessionRecord> {
1557 journal
1558 .records()
1559 .iter()
1560 .rev()
1561 .find(|record| record.kind == LIFECYCLE_SIDEBAND)
1562 .and_then(|record| serde_json::from_value(record.value.clone()).ok())
1563}
1564
1565fn append_lifecycle(journal: &mut SessionJournal, record: &SessionRecord) -> Result<(), ApiError> {
1566 journal
1567 .append_control(
1568 LIFECYCLE_SIDEBAND,
1569 now(),
1570 serde_json::to_value(record).map_err(ApiError::internal)?,
1571 )
1572 .map_err(ApiError::internal)
1573}
1574
1575fn commands(journal: &SessionJournal) -> BTreeMap<String, SessionCommand> {
1576 let mut commands = BTreeMap::new();
1577 for record in journal
1578 .records()
1579 .iter()
1580 .filter(|record| record.kind == COMMAND_SIDEBAND)
1581 {
1582 if let Ok(command) = serde_json::from_value::<SessionCommand>(record.value.clone()) {
1583 commands.insert(command.id.clone(), command);
1584 }
1585 }
1586 commands
1587}
1588
1589fn append_command(journal: &mut SessionJournal, command: &SessionCommand) -> Result<(), ApiError> {
1590 journal
1591 .append_control(
1592 COMMAND_SIDEBAND,
1593 now(),
1594 serde_json::to_value(command).map_err(ApiError::internal)?,
1595 )
1596 .map_err(ApiError::internal)
1597}
1598
1599fn materialize(mut record: SessionRecord, journal: &SessionJournal) -> SessionRecord {
1600 let log = journal.list();
1601 record.state["sessionId"] = json!(log.header.session_id);
1602 if !record.state.get("transcript").is_some_and(Value::is_array) {
1603 record.state["transcript"] = Value::Array(
1604 log.events
1605 .iter()
1606 .enumerate()
1607 .filter_map(|(position, event)| transcript_entry(position, event))
1608 .collect(),
1609 );
1610 }
1611 if !record.state.get("events").is_some_and(Value::is_array) {
1612 record.state["events"] = serde_json::to_value(&log.events).unwrap_or(Value::Null);
1613 }
1614 if !record
1615 .state
1616 .get("chatendText")
1617 .is_some_and(Value::is_string)
1618 {
1619 record.state["chatendText"] = json!(
1620 log.events
1621 .iter()
1622 .map(|event| format!("[{}]\n{}", role_name(event.role), display_text(event)))
1623 .collect::<Vec<_>>()
1624 .join("\n\n")
1625 );
1626 }
1627 record
1628}
1629
1630fn summary_state(control: &Value, journal: &SessionJournal) -> Value {
1631 let log = journal.list();
1632 let first_user = log
1633 .events
1634 .iter()
1635 .find(|event| event.role == Role::UserMessage)
1636 .map(display_text)
1637 .map(|text| text.chars().take(512).collect::<String>());
1638 json!({
1639 "sessionType":control.get("sessionType"),
1640 "channel":control.get("channel"),
1641 "freeTime":control.get("freeTime"),
1642 "orchestration":control.get("orchestration"),
1643 "ingressSource":control.get("ingressSource"),
1644 "firstUserMessage":first_user,
1645 "boxCount":log.events.len(),
1646 "eventCount":log.events.len(),
1647 "pendingTurn":control.get("pendingTurn").cloned().unwrap_or(Value::Bool(false)),
1648 })
1649}
1650
1651fn persisted_context_kind(event: &kcode_session_log::SessionEvent) -> Option<Value> {
1652 serde_json::from_str::<Value>(&event.text)
1653 .ok()?
1654 .get("kind")
1655 .cloned()
1656}
1657
1658fn display_text(event: &kcode_session_log::SessionEvent) -> String {
1659 persisted_context_kind(event)
1660 .and_then(|kind| {
1661 (kind.get("type").and_then(Value::as_str) == Some("box_created"))
1662 .then(|| {
1663 kind.get("content")
1664 .and_then(|content| content.get("text"))
1665 .and_then(Value::as_str)
1666 .map(str::to_owned)
1667 })
1668 .flatten()
1669 })
1670 .unwrap_or_else(|| event.text.clone())
1671}
1672
1673fn role_name(role: Role) -> &'static str {
1674 match role {
1675 Role::SystemMessage => "system-message",
1676 Role::SystemError => "system-error",
1677 Role::UserMessage => "user-message",
1678 Role::KennedyMessage => "kennedy-message",
1679 Role::KennedyToolCall => "kennedy-tool-call",
1680 Role::ToolResult => "tool-result",
1681 Role::ToolError => "tool-error",
1682 Role::Object => "object",
1683 Role::PendingObject => "pending-object",
1684 }
1685}
1686
1687fn transcript_entry(position: usize, event: &kcode_session_log::SessionEvent) -> Option<Value> {
1688 let kind = persisted_context_kind(event);
1689 let box_content = kind
1690 .as_ref()
1691 .filter(|kind| kind.get("type").and_then(Value::as_str) == Some("box_created"))
1692 .and_then(|kind| kind.get("content"));
1693 let metadata = box_content
1694 .and_then(|content| content.get("metadata"))
1695 .filter(|value| value.is_object());
1696 let role = match event.role {
1697 Role::UserMessage => "user",
1698 Role::KennedyMessage => "kennedy",
1699 Role::SystemError => "system",
1700 Role::SystemMessage => (box_content?
1701 .get("metadata")
1702 .and_then(|metadata| metadata.get("transcriptRole"))
1703 .and_then(Value::as_str)
1704 == Some("system"))
1705 .then_some("system")?,
1706 _ => return None,
1707 };
1708 let mut item = json!({
1709 "role":role,
1710 "content":display_text(event),
1711 "boxId":position + 1,
1712 });
1713 if let Some(objects) = box_content
1714 .and_then(|content| content.get("objects"))
1715 .filter(|value| value.is_array())
1716 {
1717 item["objects"] = objects.clone();
1718 }
1719 if let Some(metadata) = metadata {
1720 for key in ["inputKind", "externalEventId"] {
1721 if let Some(value) = metadata.get(key) {
1722 item[key] = value.clone();
1723 }
1724 }
1725 if let Some(attachments) = metadata.get("attachments").filter(|value| value.is_array()) {
1726 item["attachments"] = attachments.clone();
1727 } else if let Some(media) = metadata.get("media").filter(|value| value.is_object()) {
1728 item["attachments"] = json!([media]);
1729 }
1730 }
1731 Some(item)
1732}
1733
1734fn control_state(value: &Value) -> Value {
1735 const KEYS: &[&str] = &[
1736 "format",
1737 "version",
1738 "stateVersion",
1739 "sessionId",
1740 "sessionType",
1741 "sourceSessionType",
1742 "channel",
1743 "freeTime",
1744 "selfTimeIntent",
1745 "orchestration",
1746 "provenanceId",
1747 "rustLibSessionId",
1748 "rootNodeIds",
1749 "referenceRootNodeIds",
1750 "startedAt",
1751 "pendingTurn",
1752 "pendingExternalEventId",
1753 "roundsUsed",
1754 "completed",
1755 "sessionObjectId",
1756 "commitReceipt",
1757 "commitAuthor",
1758 "kwebPlan",
1759 "startIdempotencyId",
1760 "ingressSource",
1761 "historyIngress",
1762 ];
1763 let mut output = Map::new();
1764 for key in KEYS {
1765 if let Some(item) = value.get(*key) {
1766 if *key == "commitReceipt" && item.is_null() {
1767 continue;
1768 }
1769 let item = if *key == "historyIngress" {
1770 control_state(item)
1771 } else {
1772 item.clone()
1773 };
1774 output.insert((*key).into(), item);
1775 }
1776 }
1777 Value::Object(output)
1778}
1779
1780fn compact_control_journals(directory: &FilePath) -> anyhow::Result<()> {
1781 let mut paths = std::fs::read_dir(directory)?
1782 .filter_map(Result::ok)
1783 .map(|entry| entry.path())
1784 .filter(|path| path.extension().and_then(|value| value.to_str()) == Some(CONTROL_EXTENSION))
1785 .collect::<Vec<_>>();
1786 paths.sort();
1787 for path in paths {
1788 compact_control_journal(&path)?;
1789 }
1790 Ok(())
1791}
1792
1793fn compact_control_journal(path: &FilePath) -> anyhow::Result<()> {
1794 let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
1795 let original_bytes = file.metadata()?.len();
1796 if original_bytes >= 16 * 1024 * 1024 {
1797 tracing::info!(
1798 path = %path.display(),
1799 original_bytes,
1800 "Compacting legacy Session History control journal"
1801 );
1802 }
1803 let mut reader = BufReader::new(file);
1804 let mut latest_lifecycle = None;
1805 let mut latest_commands = BTreeMap::<String, (u64, ControlRecord)>::new();
1806 let mut retained_other = Vec::new();
1807 let mut sequence = 0_u64;
1808 let mut needs_rewrite = false;
1809
1810 loop {
1811 let mut line = Vec::new();
1812 let bytes = reader.read_until(b'\n', &mut line)?;
1813 if bytes == 0 {
1814 break;
1815 }
1816 if line.last() != Some(&b'\n') {
1817 needs_rewrite = true;
1818 break;
1819 }
1820 line.pop();
1821 if line.last() == Some(&b'\r') {
1822 line.pop();
1823 }
1824 let mut record = parse_control_record(&line)?;
1825 match record.kind.as_str() {
1826 LIFECYCLE_SIDEBAND => {
1827 if let Some(state) = record.value.get_mut("state") {
1828 let projected = control_state(state);
1829 if *state != projected {
1830 *state = projected;
1831 needs_rewrite = true;
1832 }
1833 }
1834 if latest_lifecycle.replace((sequence, record)).is_some() {
1835 needs_rewrite = true;
1836 }
1837 }
1838 COMMAND_SIDEBAND => {
1839 let id = record
1840 .value
1841 .get("id")
1842 .and_then(Value::as_str)
1843 .context("session command record has no ID")?
1844 .to_owned();
1845 if latest_commands.insert(id, (sequence, record)).is_some() {
1846 needs_rewrite = true;
1847 }
1848 }
1849 _ => retained_other.push((sequence, record)),
1850 }
1851 sequence += 1;
1852 }
1853 drop(reader);
1854
1855 if !needs_rewrite {
1856 return Ok(());
1857 }
1858
1859 let mut retained = retained_other;
1860 retained.extend(latest_lifecycle);
1861 retained.extend(latest_commands.into_values());
1862 retained.sort_by_key(|(sequence, _)| *sequence);
1863 rewrite_control_journal(path, retained.into_iter().map(|(_, record)| record))?;
1864 tracing::info!(
1865 path = %path.display(),
1866 original_bytes,
1867 compacted_bytes = std::fs::metadata(path)?.len(),
1868 "Compacted Session History control journal"
1869 );
1870 Ok(())
1871}
1872
1873fn parse_control_record(line: &[u8]) -> anyhow::Result<ControlRecord> {
1874 let separator = line
1875 .iter()
1876 .position(|byte| *byte == b' ')
1877 .context("session-control record has no checksum separator")?;
1878 let expected = std::str::from_utf8(&line[..separator])?;
1879 let payload = &line[separator + 1..];
1880 ensure!(
1881 hex_sha256(payload) == expected,
1882 "session-control record checksum mismatch"
1883 );
1884 serde_json::from_slice(payload).context("decoding session-control record")
1885}
1886
1887fn rewrite_control_journal(
1888 path: &FilePath,
1889 records: impl IntoIterator<Item = ControlRecord>,
1890) -> anyhow::Result<()> {
1891 let parent = path.parent().unwrap_or_else(|| FilePath::new("."));
1892 let file_name = path
1893 .file_name()
1894 .and_then(|value| value.to_str())
1895 .context("session-control filename is not valid UTF-8")?;
1896 let temporary = parent.join(format!(".{file_name}.compact-{}.tmp", Uuid::new_v4()));
1897 let result = (|| -> anyhow::Result<()> {
1898 let mut file = OpenOptions::new()
1899 .create_new(true)
1900 .write(true)
1901 .open(&temporary)
1902 .with_context(|| format!("creating {}", temporary.display()))?;
1903 file.set_permissions(std::fs::metadata(path)?.permissions())?;
1904 for record in records {
1905 write_control_record(&mut file, &record)?;
1906 }
1907 file.sync_all()?;
1908 std::fs::rename(&temporary, path)
1909 .with_context(|| format!("installing compacted {}", path.display()))?;
1910 sync_directory(parent)?;
1911 Ok(())
1912 })();
1913 if result.is_err() && temporary.exists() {
1914 let _ = std::fs::remove_file(&temporary);
1915 }
1916 result
1917}
1918
1919fn write_control_record(file: &mut File, record: &ControlRecord) -> anyhow::Result<()> {
1920 let payload = serde_json::to_vec(record)?;
1921 writeln!(
1922 file,
1923 "{} {}",
1924 hex_sha256(&payload),
1925 String::from_utf8(payload)?
1926 )?;
1927 Ok(())
1928}
1929
1930fn journal_paths(directory: &FilePath) -> Result<Vec<PathBuf>, ApiError> {
1931 let mut paths = std::fs::read_dir(directory)
1932 .map_err(ApiError::internal)?
1933 .filter_map(Result::ok)
1934 .map(|entry| entry.path())
1935 .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("session-log"))
1936 .collect::<Vec<_>>();
1937 paths.sort();
1938 Ok(paths)
1939}
1940
1941fn read_completed_ids(path: &FilePath) -> anyhow::Result<Vec<String>> {
1942 Ok(read_completion_receipts(path)?
1943 .into_iter()
1944 .map(|receipt| receipt.session_object_id)
1945 .collect())
1946}
1947
1948fn read_completion_receipts(path: &FilePath) -> anyhow::Result<Vec<CompletionReceipt>> {
1949 let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
1950 let mut receipts = Vec::new();
1951 let mut seen = HashSet::new();
1952 for line in BufReader::new(file).lines() {
1953 let line = line?;
1954 let line = line.trim();
1955 if line.is_empty() {
1956 continue;
1957 }
1958 let receipt = if line.starts_with('{') {
1959 serde_json::from_str::<CompletionReceipt>(line)
1960 .context("decoding Session History completion receipt")?
1961 } else {
1962 CompletionReceipt {
1963 transaction_id: None,
1964 session_object_id: line.to_owned(),
1965 session_id: None,
1966 session_type: None,
1967 created_at: None,
1968 committed_at: None,
1969 ingress_source: None,
1970 node_ids: BTreeMap::new(),
1971 object_ids: BTreeMap::new(),
1972 }
1973 };
1974 if seen.insert(receipt.session_object_id.clone()) {
1975 receipts.push(receipt);
1976 }
1977 }
1978 Ok(receipts)
1979}
1980
1981#[cfg(test)]
1982fn append_completed_id(path: &FilePath, id: &str) -> anyhow::Result<()> {
1983 append_completion_receipt(
1984 path,
1985 &CompletionReceipt {
1986 transaction_id: None,
1987 session_object_id: id.into(),
1988 session_id: None,
1989 session_type: None,
1990 created_at: None,
1991 committed_at: None,
1992 ingress_source: None,
1993 node_ids: BTreeMap::new(),
1994 object_ids: BTreeMap::new(),
1995 },
1996 )
1997}
1998
1999fn append_completion_receipt(path: &FilePath, receipt: &CompletionReceipt) -> anyhow::Result<()> {
2000 if read_completed_ids(path)?
2001 .iter()
2002 .any(|existing| existing == &receipt.session_object_id)
2003 {
2004 return Ok(());
2005 }
2006 let mut file = OpenOptions::new().append(true).open(path)?;
2007 writeln!(file, "{}", serde_json::to_string(receipt)?)?;
2008 file.flush()?;
2009 file.sync_data()?;
2010 Ok(())
2011}
2012
2013fn create_private_directory(path: &FilePath) -> anyhow::Result<()> {
2014 if path.is_dir() {
2015 return Ok(());
2016 }
2017 let mut builder = std::fs::DirBuilder::new();
2018 builder.recursive(true);
2019 #[cfg(unix)]
2020 {
2021 use std::os::unix::fs::DirBuilderExt as _;
2022 builder.mode(0o700);
2023 }
2024 builder
2025 .create(path)
2026 .with_context(|| format!("creating {}", path.display()))?;
2027 let parent = path
2028 .parent()
2029 .filter(|parent| !parent.as_os_str().is_empty())
2030 .unwrap_or_else(|| FilePath::new("."));
2031 sync_directory(parent)
2032}
2033
2034fn sync_directory(path: &FilePath) -> anyhow::Result<()> {
2035 File::open(path)
2036 .with_context(|| format!("opening directory {} for sync", path.display()))?
2037 .sync_all()
2038 .with_context(|| format!("syncing directory {}", path.display()))
2039}
2040
2041fn validate_started_at(value: &str) -> Result<(), ApiError> {
2042 DateTime::parse_from_rfc3339(value)
2043 .map(|_| ())
2044 .map_err(|_| ApiError::bad("started_at must be an RFC 3339 timestamp"))
2045}
2046
2047fn validate_idempotency(value: &str) -> Result<(), ApiError> {
2048 if value.is_empty() || value.len() > 255 {
2049 return Err(ApiError::bad(
2050 "idempotency_id must contain between 1 and 255 bytes",
2051 ));
2052 }
2053 Ok(())
2054}
2055
2056fn validate_session_id(value: &str) -> Result<(), ApiError> {
2057 Uuid::parse_str(value)
2058 .map(|_| ())
2059 .map_err(|_| ApiError::bad("invalid session ID"))
2060}
2061
2062fn require_version(record: &SessionRecord, expected: i64) -> Result<(), ApiError> {
2063 if record.version != expected {
2064 return Err(ApiError::conflict(format!(
2065 "Expected session version {expected}, found {}.",
2066 record.version
2067 )));
2068 }
2069 Ok(())
2070}
2071
2072fn now() -> String {
2073 Utc::now().to_rfc3339()
2074}
2075
2076#[cfg(test)]
2077mod tests {
2078 use std::time::{SystemTime, UNIX_EPOCH};
2079
2080 use super::*;
2081
2082 fn root(label: &str) -> PathBuf {
2083 std::env::temp_dir().join(format!(
2084 "kennedy-session-history-{label}-{}-{}",
2085 std::process::id(),
2086 SystemTime::now()
2087 .duration_since(UNIX_EPOCH)
2088 .unwrap()
2089 .as_nanos()
2090 ))
2091 }
2092
2093 fn service(label: &str) -> SessionHistory {
2094 let root = root(label);
2095 std::fs::create_dir_all(&root).unwrap();
2096 SessionHistory::open(Config {
2097 directory: root.join("sessions"),
2098 completed_list: root.join("session-history.txt"),
2099 })
2100 .unwrap()
2101 }
2102
2103 async fn start(service: &SessionHistory, idempotency_id: &str) -> SessionRecord {
2104 service
2105 .start(StartSession {
2106 idempotency_id: idempotency_id.into(),
2107 started_at: "2026-07-23T00:00:00Z".into(),
2108 session_type: "conversation".into(),
2109 duration_minutes: None,
2110 custom_prompt: None,
2111 })
2112 .await
2113 .unwrap()
2114 .value
2115 }
2116
2117 #[test]
2118 fn control_state_retains_only_authoritative_lifecycle_and_recovery_fields() {
2119 let saved = control_state(&json!({
2120 "sessionType":"conversation",
2121 "pendingTurn":true,
2122 "transcript":[{"role":"user","content":"hello"}],
2123 "boxCount":1,
2124 "eventCount":1,
2125 "boxes":{"1":{"id":1}},
2126 "events":[{"id":1}],
2127 "context":{"estimatedTokens":10},
2128 "chatendText":"hello",
2129 "historyIngress":{
2130 "format":"kennedy-chatend",
2131 "sessionType":"history-ingress",
2132 "completed":true,
2133 "commitReceipt":{"sessionObjectId":"A1234567"},
2134 "boxes":{"2":{"id":2}},
2135 "events":[{"id":2}],
2136 "context":{"estimatedTokens":20},
2137 "chatendText":"ingress"
2138 },
2139 "unrecognized":"discard me"
2140 }));
2141 assert_eq!(saved["sessionType"], "conversation");
2142 assert_eq!(saved["pendingTurn"], true);
2143 assert_eq!(saved["historyIngress"]["sessionType"], "history-ingress");
2144 assert_eq!(saved["historyIngress"]["completed"], true);
2145 assert_eq!(
2146 saved["historyIngress"]["commitReceipt"]["sessionObjectId"],
2147 "A1234567"
2148 );
2149 for field in [
2150 "transcript",
2151 "boxCount",
2152 "eventCount",
2153 "boxes",
2154 "events",
2155 "context",
2156 "chatendText",
2157 ] {
2158 assert!(saved.get(field).is_none(), "{field} was retained");
2159 assert!(
2160 saved["historyIngress"].get(field).is_none(),
2161 "nested {field} was retained"
2162 );
2163 }
2164 assert!(saved.get("unrecognized").is_none());
2165 assert!(
2166 control_state(&json!({"commitReceipt":null}))
2167 .get("commitReceipt")
2168 .is_none()
2169 );
2170 }
2171
2172 #[test]
2173 fn startup_compacts_superseded_control_records_without_losing_recovery_state() {
2174 let root = root("control-compaction");
2175 let sessions = root.join("sessions");
2176 std::fs::create_dir_all(&sessions).unwrap();
2177 let id = "9078bb6e-0931-4477-9ce9-b1430d0335a2";
2178 drop(
2179 SessionStore::new(&sessions)
2180 .create_session(id, "2026-07-24T00:00:00Z")
2181 .unwrap(),
2182 );
2183 let control_path = control_path(&sessions, id);
2184 let mut file = File::create(&control_path).unwrap();
2185 let base = SessionRecord {
2186 id: id.into(),
2187 phase: "active".into(),
2188 started_at: "2026-07-24T00:00:00Z".into(),
2189 updated_at: "2026-07-24T00:00:00Z".into(),
2190 state: json!({
2191 "sessionType":"conversation",
2192 "boxes":{"1":{"canonical":{"content":{"text":"x".repeat(20_000)}}}},
2193 "events":[{"kind":"old presentation"}],
2194 "context":{"estimatedTokens":5_000},
2195 "chatendText":"x".repeat(20_000),
2196 }),
2197 provenance_id: None,
2198 version: 1,
2199 last_user_message_at: None,
2200 ended_at: None,
2201 ingress_failure_count: 0,
2202 ingress_failures: json!([]),
2203 ingress_next_attempt_at: None,
2204 summary: false,
2205 };
2206 write_control_record(
2207 &mut file,
2208 &ControlRecord {
2209 kind: LIFECYCLE_SIDEBAND.into(),
2210 recorded_at: "2026-07-24T00:00:00Z".into(),
2211 value: serde_json::to_value(&base).unwrap(),
2212 },
2213 )
2214 .unwrap();
2215 let pending_command = SessionCommand {
2216 id: "command-1".into(),
2217 conversation_id: id.into(),
2218 sequence: 1,
2219 kind: "message".into(),
2220 payload: json!({"text":"hello"}),
2221 status: "pending".into(),
2222 cancel_requested: false,
2223 outcome: None,
2224 created_at: "2026-07-24T00:00:01Z".into(),
2225 processing_started_at: None,
2226 completed_at: None,
2227 idempotency_id: "message-1".into(),
2228 };
2229 write_control_record(
2230 &mut file,
2231 &ControlRecord {
2232 kind: COMMAND_SIDEBAND.into(),
2233 recorded_at: "2026-07-24T00:00:01Z".into(),
2234 value: serde_json::to_value(&pending_command).unwrap(),
2235 },
2236 )
2237 .unwrap();
2238 let mut latest = base;
2239 latest.version = 2;
2240 latest.updated_at = "2026-07-24T00:00:02Z".into();
2241 latest.state["pendingTurn"] = json!(true);
2242 latest.state["historyIngress"] = json!({
2243 "format":"kennedy-chatend",
2244 "sessionType":"history-ingress",
2245 "completed":true,
2246 "commitReceipt":{"sessionObjectId":"A1234567"},
2247 "boxes":{"2":{"canonical":{"content":{"text":"y".repeat(20_000)}}}},
2248 "events":[{"kind":"ingress presentation"}],
2249 "context":{"estimatedTokens":7_000},
2250 "chatendText":"y".repeat(20_000),
2251 });
2252 write_control_record(
2253 &mut file,
2254 &ControlRecord {
2255 kind: LIFECYCLE_SIDEBAND.into(),
2256 recorded_at: "2026-07-24T00:00:02Z".into(),
2257 value: serde_json::to_value(&latest).unwrap(),
2258 },
2259 )
2260 .unwrap();
2261 let mut completed_command = pending_command;
2262 completed_command.status = "complete".into();
2263 completed_command.outcome = Some(json!({"accepted":true}));
2264 completed_command.completed_at = Some("2026-07-24T00:00:03Z".into());
2265 write_control_record(
2266 &mut file,
2267 &ControlRecord {
2268 kind: COMMAND_SIDEBAND.into(),
2269 recorded_at: "2026-07-24T00:00:03Z".into(),
2270 value: serde_json::to_value(&completed_command).unwrap(),
2271 },
2272 )
2273 .unwrap();
2274 file.sync_all().unwrap();
2275 drop(file);
2276 let original_bytes = std::fs::metadata(&control_path).unwrap().len();
2277
2278 let service = SessionHistory::open(Config {
2279 directory: sessions.clone(),
2280 completed_list: root.join("session-history.txt"),
2281 })
2282 .unwrap();
2283 let compacted_bytes = std::fs::metadata(&control_path).unwrap().len();
2284 assert!(compacted_bytes < original_bytes / 10);
2285 let journal = open_by_id(&service.state, id).unwrap();
2286 assert_eq!(journal.records().len(), 2);
2287 let restored = latest_lifecycle(&journal).unwrap();
2288 assert_eq!(restored.version, 2);
2289 assert_eq!(restored.state["pendingTurn"], true);
2290 assert!(restored.state.get("boxes").is_none());
2291 assert!(restored.state.get("events").is_none());
2292 assert!(restored.state.get("context").is_none());
2293 assert!(restored.state.get("chatendText").is_none());
2294 assert_eq!(
2295 restored.state["historyIngress"]["commitReceipt"]["sessionObjectId"],
2296 "A1234567"
2297 );
2298 assert!(restored.state["historyIngress"].get("boxes").is_none());
2299 let restored_commands = commands(&journal);
2300 assert_eq!(restored_commands.len(), 1);
2301 assert_eq!(restored_commands["command-1"].status, "complete");
2302 assert_eq!(
2303 restored_commands["command-1"].outcome,
2304 Some(json!({"accepted":true}))
2305 );
2306 }
2307
2308 #[tokio::test]
2309 async fn managed_session_log_and_control_journal_remain_coordinated() {
2310 let service = service("commands");
2311 let record = start(&service, "start-1").await;
2312 let id = record.id.clone();
2313 let mut journal = open_by_id(&service.state, &id).unwrap();
2314 journal
2315 .log
2316 .add_event(Role::SystemError, "The message exceeded capacity.")
2317 .unwrap();
2318 drop(journal);
2319 let command = service
2320 .enqueue(
2321 &id,
2322 NewCommand {
2323 idempotency_id: "message-1".into(),
2324 kind: "message".into(),
2325 payload: json!({"text":"hello"}),
2326 },
2327 )
2328 .await
2329 .unwrap()
2330 .value;
2331 assert_eq!(command.status, "pending");
2332 let materialized = service.get(&id).await.unwrap();
2333 assert!(
2334 materialized.state["chatendText"]
2335 .as_str()
2336 .is_some_and(|text| text.contains("[system-error]"))
2337 );
2338 assert_eq!(
2339 materialized.state["transcript"][0],
2340 json!({
2341 "role":"system",
2342 "content":"The message exceeded capacity.",
2343 "boxId":1,
2344 })
2345 );
2346 let listed = service.command_heads().await.unwrap();
2347 assert_eq!(listed.len(), 1);
2348 assert_eq!(
2349 std::fs::read_dir(&service.state.config.directory)
2350 .unwrap()
2351 .count(),
2352 2
2353 );
2354 }
2355
2356 #[tokio::test]
2357 async fn checkpointed_presentation_is_rebuilt_from_the_session_log() {
2358 let service = service("live-ui");
2359 let record = start(&service, "live-ui-start").await;
2360 let id = record.id.clone();
2361 let mut journal = open_by_id(&service.state, &id).unwrap();
2362 journal
2363 .log
2364 .add_event(Role::UserMessage, "hello from the log")
2365 .unwrap();
2366 drop(journal);
2367 let boxes = json!({
2368 "1":{
2369 "id":1,
2370 "owner":{"kind":"user"},
2371 "canonical":{"eventId":1,"content":{"text":"hello"}},
2372 "representation":{"kind":"hydrated"},
2373 "active":true
2374 }
2375 });
2376 let context = json!({"items":[{"boxId":1,"text":"hello"}]});
2377 let checkpointed = service
2378 .checkpoint(
2379 &id,
2380 Checkpoint {
2381 expected_version: record.version,
2382 state: json!({
2383 "sessionId":id,
2384 "sessionType":"conversation",
2385 "boxes":boxes,
2386 "events":[],
2387 "context":context,
2388 "chatendText":"hello",
2389 "historyIngress":{"format":"kennedy-chatend","version":1}
2390 }),
2391 user_activity: false,
2392 },
2393 )
2394 .await
2395 .unwrap();
2396 assert!(checkpointed.state.get("boxes").is_none());
2397 assert!(checkpointed.state.get("context").is_none());
2398 assert_eq!(
2399 checkpointed.state["transcript"][0]["content"],
2400 "hello from the log"
2401 );
2402 assert_eq!(checkpointed.state["events"][0]["role"], "user-message");
2403 assert!(
2404 checkpointed.state["chatendText"]
2405 .as_str()
2406 .is_some_and(|text| text.contains("hello from the log"))
2407 );
2408 assert_eq!(
2409 checkpointed.state["historyIngress"]["format"],
2410 "kennedy-chatend"
2411 );
2412
2413 let fetched = service.get(&id).await.unwrap();
2414 assert!(fetched.state.get("boxes").is_none());
2415 assert!(fetched.state.get("context").is_none());
2416 assert_eq!(
2417 fetched.state["transcript"][0]["content"],
2418 "hello from the log"
2419 );
2420 }
2421
2422 #[tokio::test]
2423 async fn active_pending_objects_are_streamed_with_original_metadata() {
2424 let service = service("pending-object");
2425 let record = start(&service, "pending-object-start").await;
2426 let id = record.id;
2427 let mut journal = open_by_id(&service.state, &id).unwrap();
2428 let position = journal
2429 .log
2430 .add_pending_object(
2431 "object event",
2432 "photo.png",
2433 "image/png",
2434 b"\x89PNG\r\n\x1a\npayload",
2435 )
2436 .unwrap();
2437 let object = service
2438 .object(&id, &format!("pending:{}", position.index() + 1))
2439 .unwrap();
2440 assert_eq!(object.media_type, "image/png");
2441 assert_eq!(object.file_name, "photo.png");
2442 assert_eq!(object.bytes, b"\x89PNG\r\n\x1a\npayload");
2443 }
2444
2445 #[tokio::test]
2446 async fn conversation_ingress_failures_back_off_then_stop_and_can_be_retried() {
2447 let service = service("ingress-retries");
2448 let created = start(&service, "ingress-retries-start").await;
2449 let id = created.id.clone();
2450 let mut record = service
2451 .request_ingress(
2452 &id,
2453 Checkpoint {
2454 expected_version: created.version,
2455 state: created.state,
2456 user_activity: false,
2457 },
2458 )
2459 .await
2460 .unwrap();
2461
2462 for attempt in 1..=INGRESS_FAILURE_LIMIT {
2463 record = service
2464 .start_ingress(
2465 &id,
2466 StartIngress {
2467 expected_version: record.version,
2468 provenance_id: format!("session:{id}"),
2469 },
2470 )
2471 .await
2472 .unwrap();
2473 record = service
2474 .fail_ingress(
2475 &id,
2476 IngressFailure {
2477 expected_version: record.version,
2478 stage: "model_loop".into(),
2479 code: Some("ingress_error".into()),
2480 message: "transient failure".into(),
2481 rounds_used: None,
2482 context_tokens: None,
2483 context_window_tokens: None,
2484 },
2485 )
2486 .await
2487 .unwrap();
2488 assert_eq!(record.ingress_failure_count, attempt);
2489 if attempt < INGRESS_FAILURE_LIMIT {
2490 assert_eq!(record.phase, "ingress_pending");
2491 assert!(record.ingress_next_attempt_at.is_some());
2492 } else {
2493 assert_eq!(record.phase, "ingress_failed");
2494 assert!(record.ingress_next_attempt_at.is_none());
2495 }
2496 }
2497 assert_eq!(
2498 record.ingress_failures.as_array().unwrap().len(),
2499 RETAINED_INGRESS_FAILURES
2500 );
2501
2502 let retried = service
2503 .retry_ingress(
2504 &id,
2505 RetryIngress {
2506 expected_version: record.version,
2507 state: record.state,
2508 },
2509 )
2510 .await
2511 .unwrap();
2512 assert_eq!(retried.phase, "ingress_pending");
2513 assert_eq!(retried.ingress_failure_count, 0);
2514 assert!(retried.ingress_next_attempt_at.is_none());
2515 }
2516
2517 #[tokio::test]
2518 async fn prepared_ingress_is_idempotent_through_completion() {
2519 let service = service("prepared-ingress");
2520 let input = NewIngressSession {
2521 idempotency_id: "audio:recording:0".into(),
2522 started_at: "2026-01-02T03:04:05Z".into(),
2523 source_session_type: "audio".into(),
2524 kind: chatend::SessionKind::AudioIngress,
2525 effective_context_tokens: 100_000,
2526 text: "Prepared transcript".into(),
2527 metadata: json!({
2528 "kind":"audio-transcript",
2529 "recordingId":"recording",
2530 "pieceIndex":0,
2531 "pieceCount":1,
2532 }),
2533 };
2534 let created = service.enqueue_ingress(input.clone()).await.unwrap();
2535 assert!(created.created);
2536 assert_eq!(created.value.phase, "ingress_pending");
2537 assert_eq!(
2538 created.value.state["transcript"][0]["content"],
2539 "Prepared transcript"
2540 );
2541 assert_eq!(
2542 created.value.state["ingressSource"]["metadata"]["recordingId"],
2543 "recording"
2544 );
2545
2546 let replay = service.enqueue_ingress(input.clone()).await.unwrap();
2547 assert!(!replay.created);
2548 assert_eq!(replay.value.id, created.value.id);
2549
2550 let mut state = replay.value.state;
2551 state["historyIngress"] = json!({
2552 "completed":true,
2553 "sessionObjectId":"A1234567",
2554 "commitReceipt":{
2555 "sessionObjectId":"A1234567",
2556 "nodeIds":{},
2557 "objectIds":{}
2558 }
2559 });
2560 let checkpointed = service
2561 .checkpoint(
2562 &created.value.id,
2563 Checkpoint {
2564 expected_version: replay.value.version,
2565 state,
2566 user_activity: false,
2567 },
2568 )
2569 .await
2570 .unwrap();
2571 service
2572 .complete_ingress(
2573 &created.value.id,
2574 ExpectedVersion {
2575 expected_version: checkpointed.version,
2576 },
2577 )
2578 .await
2579 .unwrap();
2580
2581 let completed_replay = service.enqueue_ingress(input).await.unwrap();
2582 assert!(!completed_replay.created);
2583 assert_eq!(completed_replay.value.phase, "complete");
2584 assert_eq!(
2585 completed_replay.value.state["ingressSource"]["metadata"]["recordingId"],
2586 "recording"
2587 );
2588 }
2589
2590 #[tokio::test]
2591 async fn startup_releases_legacy_hot_loop_without_discarding_checkpoint_state() {
2592 let service = service("ingress-release");
2593 let created = start(&service, "ingress-release-start").await;
2594 let id = created.id.clone();
2595 let pending = service
2596 .request_ingress(
2597 &id,
2598 Checkpoint {
2599 expected_version: created.version,
2600 state: json!({
2601 "sessionType":"conversation",
2602 "historyIngress":{"kwebPlan":{"creates":[{"pendingId":"pending:9"}]}}
2603 }),
2604 user_activity: false,
2605 },
2606 )
2607 .await
2608 .unwrap();
2609 let started = service
2610 .start_ingress(
2611 &id,
2612 StartIngress {
2613 expected_version: pending.version,
2614 provenance_id: format!("session:{id}"),
2615 },
2616 )
2617 .await
2618 .unwrap();
2619 let mut journal = open_by_id(&service.state, &id).unwrap();
2620 let mut legacy = started;
2621 legacy.ingress_failure_count = 150;
2622 legacy.ingress_failures = Value::Array(
2623 (1..=150)
2624 .map(|attempt| json!({"attempt":attempt,"message":"legacy hot loop"}))
2625 .collect(),
2626 );
2627 legacy.version += 1;
2628 append_lifecycle(&mut journal, &legacy).unwrap();
2629 drop(journal);
2630
2631 let released = service.release_interrupted_ingress().await.unwrap();
2632 assert_eq!(released, vec![id.clone()]);
2633 let repaired = service.get(&id).await.unwrap();
2634 assert_eq!(repaired.phase, "ingress_pending");
2635 assert_eq!(repaired.ingress_failure_count, 0);
2636 assert_eq!(
2637 repaired.ingress_failures.as_array().unwrap().len(),
2638 RETAINED_INGRESS_FAILURES
2639 );
2640 assert_eq!(
2641 repaired.state["historyIngress"]["kwebPlan"]["creates"][0]["pendingId"],
2642 "pending:9"
2643 );
2644 }
2645
2646 #[tokio::test]
2647 async fn nested_history_ingress_receipt_completes_the_source_session() {
2648 let service = service("nested-completion");
2649 let record = start(&service, "nested-completion-start").await;
2650 let id = record.id.clone();
2651 let mut state = record.state.clone();
2652 state["historyIngress"] = json!({
2653 "sessionType":"history-ingress",
2654 "completed":true,
2655 "sessionObjectId":"A1234567",
2656 "commitReceipt":{
2657 "transactionId":"T1234567",
2658 "sessionObjectId":"A1234567",
2659 "nodeIds":{},
2660 "objectIds":{}
2661 }
2662 });
2663 let completed = service
2664 .complete(
2665 &id,
2666 Checkpoint {
2667 expected_version: record.version,
2668 state,
2669 user_activity: false,
2670 },
2671 )
2672 .await
2673 .unwrap();
2674 assert_eq!(completed.phase, "complete");
2675 assert_eq!(completed.state["sessionObjectId"], "A1234567");
2676 assert_eq!(
2677 completed.state["commitReceipt"]["transactionId"],
2678 "T1234567"
2679 );
2680 assert!(
2681 !service
2682 .state
2683 .config
2684 .directory
2685 .join(format!("{id}.session-log"))
2686 .exists()
2687 );
2688 assert!(!control_path(&service.state.config.directory, &id).exists());
2689 assert_eq!(
2690 read_completion_receipts(&service.state.config.completed_list).unwrap()[0]
2691 .session_object_id,
2692 "A1234567"
2693 );
2694 }
2695
2696 #[tokio::test]
2697 async fn committed_history_records_a_structured_receipt() {
2698 let service = service("completed");
2699 append_completed_id(&service.state.config.completed_list, "A1234567").unwrap();
2700 let listed = service.list().await.unwrap();
2701 assert_eq!(listed[0].state["sessionObjectId"], "A1234567");
2702 assert_eq!(
2703 listed[0].state["commitReceipt"]["sessionObjectId"],
2704 "A1234567"
2705 );
2706 let stored = std::fs::read_to_string(&service.state.config.completed_list).unwrap();
2707 let receipt: CompletionReceipt = serde_json::from_str(stored.trim()).unwrap();
2708 assert_eq!(receipt.session_object_id, "A1234567");
2709 }
2710}