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