1pub mod chatend;
10pub use chatend::Session;
11
12use std::{
13 collections::{BTreeMap, HashMap, HashSet},
14 fs::{File, OpenOptions},
15 io::{BufRead, BufReader, Write},
16 path::{Path as FilePath, PathBuf},
17 sync::{
18 Arc, Mutex, Weak,
19 atomic::{AtomicBool, Ordering},
20 },
21};
22
23use anyhow::{Context as _, ensure};
24use chrono::{DateTime, Duration, Utc};
25use kcode_session_control_journal::{Journal as ControlJournal, Record as ControlRecord};
26use kcode_session_log::{EventPosition, Role, Session as DurableSession, SessionLog, SessionStore};
27use serde::{Deserialize, Serialize};
28use serde_json::{Map, Value, json};
29use tokio::sync::Notify;
30use uuid::Uuid;
31
32const LIFECYCLE_SIDEBAND: &str = "session_lifecycle";
33const COMMAND_SIDEBAND: &str = "session_command";
34const STOP_SIDEBAND: &str = "session_stop";
35const CONTROL_EXTENSION: &str = "session-control";
36const INGRESS_FAILURE_LIMIT: i64 = 5;
37const INGRESS_RETRY_DELAY_SECONDS: i64 = 15;
38const RETAINED_INGRESS_FAILURES: usize = 5;
39
40struct SessionJournal {
41 log: DurableSession,
42 control: ControlJournal,
43}
44
45impl SessionJournal {
46 fn create(directory: &FilePath, id: &str, created_at: &str) -> anyhow::Result<Self> {
47 let log = SessionStore::new(directory).create_session(id, created_at)?;
48 let control = ControlJournal::create(control_path(directory, id))?;
49 Ok(Self { log, control })
50 }
51
52 fn open(path: impl AsRef<FilePath>) -> anyhow::Result<Self> {
53 let path = path.as_ref();
54 ensure!(
55 path.extension().and_then(|value| value.to_str()) == Some("session-log"),
56 "{} is not a session-log path",
57 path.display()
58 );
59 let directory = path.parent().unwrap_or_else(|| FilePath::new("."));
60 let id = path
61 .file_stem()
62 .and_then(|value| value.to_str())
63 .context("session-log filename is not valid UTF-8")?;
64 let log = SessionStore::new(directory).open_session(id)?;
65 let path = control_path(directory, id);
66 let control = match ControlJournal::open(path.clone())? {
67 Some(control) => control,
68 None => ControlJournal::create(path)?,
69 };
70 Ok(Self { log, control })
71 }
72
73 fn open_existing(path: impl AsRef<FilePath>) -> anyhow::Result<Option<Self>> {
74 let path = path.as_ref();
75 ensure!(
76 path.extension().and_then(|value| value.to_str()) == Some("session-log"),
77 "{} is not a session-log path",
78 path.display()
79 );
80 let directory = path.parent().unwrap_or_else(|| FilePath::new("."));
81 let id = path
82 .file_stem()
83 .and_then(|value| value.to_str())
84 .context("session-log filename is not valid UTF-8")?;
85 let log = match SessionStore::new(directory).open_session(id) {
86 Ok(log) => log,
87 Err(_) if !path.exists() => return Ok(None),
88 Err(error) => return Err(error),
89 };
90 let Some(control) = ControlJournal::open(control_path(directory, id))? else {
91 return Ok(None);
92 };
93 Ok(Some(Self { log, control }))
94 }
95
96 fn list(&self) -> SessionLog {
97 self.log.list()
98 }
99
100 fn records(&self) -> &[ControlRecord] {
101 self.control.records()
102 }
103
104 fn append_control(
105 &mut self,
106 kind: impl Into<String>,
107 recorded_at: impl Into<String>,
108 value: Value,
109 ) -> anyhow::Result<()> {
110 self.control.append(kind, recorded_at, value)
111 }
112
113 fn stage_object(
114 &mut self,
115 media_type: String,
116 file_name: Option<String>,
117 bytes: &[u8],
118 ) -> anyhow::Result<String> {
119 let file_name = file_name.unwrap_or_else(|| "uploaded-object".into());
120 let position =
121 self.log
122 .add_pending_object(file_name.clone(), file_name, media_type, bytes)?;
123 Ok(format!("pending:{}", position.index() + 1))
124 }
125}
126
127fn control_path(directory: &FilePath, id: &str) -> PathBuf {
128 directory.join(format!("{id}.{CONTROL_EXTENSION}"))
129}
130
131#[derive(Clone, Debug)]
132pub struct Config {
133 pub directory: PathBuf,
134 pub completed_list: PathBuf,
135 pub provider_cost_compatibility: Option<ProviderCostCompatibility>,
136}
137
138#[derive(Clone, Copy)]
140pub struct ProviderCostCompatibility {
141 pub session_model: fn(&Value) -> Option<String>,
142 pub estimator: chatend::ProviderCostEstimator,
143}
144
145impl std::fmt::Debug for ProviderCostCompatibility {
146 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 formatter
148 .debug_struct("ProviderCostCompatibility")
149 .finish_non_exhaustive()
150 }
151}
152
153#[derive(Clone, Debug)]
154pub struct NewSession {
155 pub kind: chatend::SessionKind,
156 pub created_at: String,
157 pub effective_context_tokens: u64,
158 pub channel: Value,
159}
160
161#[derive(Clone)]
162struct AppState {
163 config: Config,
164 catalog_mutation: Arc<Mutex<()>>,
165 session_mutations: Arc<Mutex<HashMap<String, Weak<Mutex<()>>>>>,
166 stop_listeners: Arc<Mutex<HashMap<String, Weak<StopSignal>>>>,
167}
168
169#[derive(Clone)]
170pub struct SessionHistory {
171 state: AppState,
172}
173
174struct StopSignal {
175 requested: AtomicBool,
176 notification: Notify,
177}
178
179#[derive(Clone)]
185pub struct StopListener {
186 signal: Arc<StopSignal>,
187}
188
189impl StopListener {
190 pub async fn requested(&self) {
191 loop {
192 let notified = self.signal.notification.notified();
193 tokio::pin!(notified);
194 notified.as_mut().enable();
195 if self.signal.requested.load(Ordering::Acquire) {
196 return;
197 }
198 notified.await;
199 }
200 }
201}
202
203#[derive(Debug)]
204pub struct Error {
205 pub kind: ErrorKind,
206 pub message: String,
207}
208
209#[derive(Clone, Copy, Debug, Eq, PartialEq)]
210pub enum ErrorKind {
211 InvalidInput,
212 NotFound,
213 Conflict,
214 Storage,
215}
216
217impl ErrorKind {
218 pub fn code(self) -> &'static str {
219 match self {
220 Self::InvalidInput => "invalid_request",
221 Self::NotFound => "not_found",
222 Self::Conflict => "state_conflict",
223 Self::Storage => "internal_error",
224 }
225 }
226}
227
228impl std::fmt::Display for Error {
229 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230 formatter.write_str(&self.message)
231 }
232}
233
234impl std::error::Error for Error {}
235
236impl From<ApiError> for Error {
237 fn from(error: ApiError) -> Self {
238 Self {
239 kind: error.kind,
240 message: error.message,
241 }
242 }
243}
244
245#[derive(Debug)]
246struct ApiError {
247 kind: ErrorKind,
248 message: String,
249}
250
251impl ApiError {
252 fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
253 Self {
254 kind,
255 message: message.into(),
256 }
257 }
258
259 fn bad(message: impl Into<String>) -> Self {
260 Self::new(ErrorKind::InvalidInput, message)
261 }
262
263 fn not_found() -> Self {
264 Self::new(ErrorKind::NotFound, "Session not found.")
265 }
266
267 fn conflict(message: impl Into<String>) -> Self {
268 Self::new(ErrorKind::Conflict, message)
269 }
270
271 fn internal(error: impl std::fmt::Display) -> Self {
272 tracing::warn!(error=%format!("{error:#}"), "Session History request failed");
273 Self::new(
274 ErrorKind::Storage,
275 "An unexpected Session History storage error occurred.",
276 )
277 }
278}
279
280#[derive(Clone, Debug, Deserialize, Serialize)]
281pub struct SessionRecord {
282 pub id: String,
283 pub phase: String,
284 pub started_at: String,
285 pub updated_at: String,
286 pub state: Value,
287 pub provenance_id: Option<String>,
288 pub version: i64,
289 pub last_user_message_at: Option<String>,
290 pub ended_at: Option<String>,
291 pub ingress_failure_count: i64,
292 pub ingress_failures: Value,
293 pub ingress_next_attempt_at: Option<String>,
294 #[serde(default, skip_serializing_if = "is_false")]
295 pub summary: bool,
296}
297
298fn is_false(value: &bool) -> bool {
299 !*value
300}
301
302#[derive(Clone, Debug, Deserialize, Serialize)]
303pub struct RegisterSession {
304 pub id: String,
305 pub started_at: String,
306 pub state: Value,
307}
308
309#[derive(Clone, Debug, Deserialize, Serialize)]
310pub struct StartSession {
311 pub idempotency_id: String,
312 pub started_at: String,
313 pub session_type: String,
314 #[serde(default)]
315 pub duration_minutes: Option<f64>,
316 #[serde(default)]
317 pub custom_prompt: Option<String>,
318}
319
320#[derive(Clone, Debug, Deserialize, Serialize)]
321pub struct NewIngressSession {
322 pub idempotency_id: String,
323 pub started_at: String,
324 pub source_session_type: String,
325 pub kind: chatend::SessionKind,
326 pub effective_context_tokens: u64,
327 pub text: String,
328 #[serde(default)]
329 pub metadata: Value,
330}
331
332#[derive(Clone, Debug, Deserialize, Serialize)]
333pub struct NewCommand {
334 pub idempotency_id: String,
335 pub kind: String,
336 #[serde(default = "empty_object")]
337 pub payload: Value,
338}
339
340fn empty_object() -> Value {
341 json!({})
342}
343
344#[derive(Clone, Debug, Deserialize, Serialize)]
345pub struct CommandOutcome {
346 #[serde(default = "empty_object")]
347 pub outcome: Value,
348}
349
350#[derive(Clone, Debug, Deserialize, Serialize)]
351pub struct NewStopRequest {
352 pub idempotency_id: String,
353 pub scope: String,
354}
355
356#[derive(Clone, Debug, Deserialize, Serialize)]
357pub struct NewCurrentWorkStop {
358 pub idempotency_id: String,
359}
360
361#[derive(Clone, Debug, Deserialize, Serialize)]
362pub struct StopOutcome {
363 #[serde(default = "empty_object")]
364 pub outcome: Value,
365}
366
367#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
368#[serde(rename_all = "camelCase")]
369pub struct SessionStopRequest {
370 pub id: String,
371 pub session_id: String,
372 pub scope: String,
373 pub status: String,
374 pub outcome: Option<Value>,
375 pub requested_at: String,
376 pub completed_at: Option<String>,
377 pub idempotency_id: String,
378}
379
380#[derive(Clone, Debug, Deserialize, Serialize)]
381#[serde(rename_all = "camelCase")]
382pub struct SessionCommand {
383 pub id: String,
384 pub conversation_id: String,
385 pub sequence: i64,
386 pub kind: String,
387 pub payload: Value,
388 pub status: String,
389 pub cancel_requested: bool,
390 pub outcome: Option<Value>,
391 pub created_at: String,
392 pub processing_started_at: Option<String>,
393 pub completed_at: Option<String>,
394 pub idempotency_id: String,
395}
396
397#[derive(Clone, Debug, Deserialize, Serialize)]
398pub struct Checkpoint {
399 pub expected_version: i64,
400 pub state: Value,
401 #[serde(default)]
402 pub user_activity: bool,
403}
404
405#[derive(Clone, Debug, Deserialize, Serialize)]
406pub struct ExpectedVersion {
407 pub expected_version: i64,
408}
409
410#[derive(Clone, Debug, Deserialize, Serialize)]
411pub struct RetryIngress {
412 pub expected_version: i64,
413 pub state: Value,
414}
415
416#[derive(Clone, Debug, Deserialize, Serialize)]
417pub struct StartIngress {
418 pub expected_version: i64,
419 pub provenance_id: String,
420}
421
422#[derive(Clone, Debug, Deserialize, Serialize)]
423pub struct IngressFailure {
424 pub expected_version: i64,
425 pub stage: String,
426 #[serde(default)]
427 pub code: Option<String>,
428 pub message: String,
429 #[serde(default)]
430 pub rounds_used: Option<u64>,
431 #[serde(default)]
432 pub context_tokens: Option<u64>,
433 #[serde(default)]
434 pub context_window_tokens: Option<u64>,
435}
436
437#[derive(Clone, Debug, Deserialize, Serialize)]
438pub struct RecordCompletion {
439 pub session_object_id: String,
440 #[serde(default)]
441 pub commit_receipt: Option<CompletionReceipt>,
442 #[serde(default)]
443 pub session_id: Option<String>,
444 #[serde(default)]
445 pub session_type: Option<String>,
446 #[serde(default)]
447 pub created_at: Option<String>,
448}
449
450#[derive(Clone, Debug, Deserialize, Serialize)]
451#[serde(rename_all = "camelCase")]
452pub struct CompletionReceipt {
453 #[serde(default)]
454 pub transaction_id: Option<String>,
455 pub session_object_id: String,
456 #[serde(default)]
457 pub session_id: Option<String>,
458 #[serde(default)]
459 pub session_type: Option<String>,
460 #[serde(default)]
461 pub created_at: Option<String>,
462 #[serde(default)]
463 pub committed_at: Option<String>,
464 #[serde(default)]
465 pub ingress_source: Option<Value>,
466 #[serde(default)]
467 pub node_ids: BTreeMap<String, String>,
468 #[serde(default)]
469 pub object_ids: BTreeMap<String, String>,
470}
471
472#[derive(Clone, Debug, Eq, PartialEq)]
473pub struct Created<T> {
474 pub value: T,
475 pub created: bool,
476}
477
478#[derive(Clone, Debug)]
479pub struct NewObject {
480 pub file_name: Option<String>,
481 pub media_type: String,
482 pub bytes: Vec<u8>,
483}
484
485#[derive(Clone, Debug)]
486pub struct StoredObject {
487 pub file_name: String,
488 pub media_type: String,
489 pub bytes: Vec<u8>,
490}
491
492impl SessionHistory {
493 pub fn open(config: Config) -> anyhow::Result<Self> {
494 create_private_directory(&config.directory)?;
495 compact_control_journals(&config.directory)?;
496 if let Some(parent) = config
497 .completed_list
498 .parent()
499 .filter(|path| !path.as_os_str().is_empty())
500 {
501 create_private_directory(parent)?;
502 }
503 if !config.completed_list.exists() {
504 let file = OpenOptions::new()
505 .create_new(true)
506 .write(true)
507 .open(&config.completed_list)
508 .with_context(|| format!("creating {}", config.completed_list.display()))?;
509 file.sync_all()?;
510 sync_directory(
511 config
512 .completed_list
513 .parent()
514 .filter(|path| !path.as_os_str().is_empty())
515 .unwrap_or_else(|| FilePath::new(".")),
516 )?;
517 }
518 Ok(Self {
519 state: AppState {
520 config,
521 catalog_mutation: Arc::new(Mutex::new(())),
522 session_mutations: Arc::new(Mutex::new(HashMap::new())),
523 stop_listeners: Arc::new(Mutex::new(HashMap::new())),
524 },
525 })
526 }
527
528 pub fn health(&self) -> Result<(), Error> {
529 read_completed_ids(&self.state.config.completed_list).map_err(ApiError::internal)?;
530 Ok(())
531 }
532
533 pub fn create_session(&self, input: NewSession) -> anyhow::Result<Session> {
534 let metadata = chatend::SessionMetadata {
535 session_id: Uuid::new_v4().to_string(),
536 kind: input.kind,
537 created_at: input.created_at,
538 effective_context_tokens: input.effective_context_tokens,
539 channel: input.channel,
540 };
541 Session::create(
542 self.state
543 .config
544 .directory
545 .join(format!("{}.session-log", metadata.session_id)),
546 metadata,
547 )
548 }
549
550 pub fn open_session(&self, metadata: chatend::SessionMetadata) -> anyhow::Result<Session> {
551 self.open_session_with_provider_model(metadata, None)
552 }
553
554 pub fn open_session_with_provider_model(
555 &self,
556 metadata: chatend::SessionMetadata,
557 provider_model: Option<&str>,
558 ) -> anyhow::Result<Session> {
559 validate_session_id(&metadata.session_id)
560 .map_err(|error| anyhow::anyhow!(error.message))?;
561 let path = self
562 .state
563 .config
564 .directory
565 .join(format!("{}.session-log", metadata.session_id));
566 match self.state.config.provider_cost_compatibility {
567 Some(compatibility) => Session::open_with_metadata_and_provider_costs(
568 path,
569 metadata,
570 provider_model,
571 Some(compatibility.estimator),
572 ),
573 None => Session::open_with_metadata(path, metadata),
574 }
575 }
576
577 pub fn legacy_provider_cost_summary_for_archive(
583 &self,
584 archive: &Value,
585 session_state: Option<&Value>,
586 ) -> anyhow::Result<Option<chatend::ProviderCostSummary>> {
587 let Some(compatibility) = self.state.config.provider_cost_compatibility else {
588 return Ok(None);
589 };
590 if is_metadata_free_session_log_archive(archive) {
591 return Ok(None);
592 }
593 let default_provider_model =
594 session_state.and_then(|state| (compatibility.session_model)(state));
595 chatend::legacy_provider_cost_summary_for_archive(
596 archive,
597 default_provider_model.as_deref(),
598 compatibility.estimator,
599 )
600 .map(Some)
601 }
602
603 pub async fn register(&self, input: RegisterSession) -> Result<SessionRecord, Error> {
604 create_session(self.state.clone(), input)
605 .await
606 .map_err(Into::into)
607 }
608
609 pub async fn start(&self, input: StartSession) -> Result<Created<SessionRecord>, Error> {
610 let (created, record) = start_managed_session(self.state.clone(), input).await?;
611 Ok(Created {
612 value: record,
613 created,
614 })
615 }
616
617 pub async fn enqueue_ingress(
618 &self,
619 input: NewIngressSession,
620 ) -> Result<Created<SessionRecord>, Error> {
621 let (created, record) = enqueue_ingress_session(self.state.clone(), input).await?;
622 Ok(Created {
623 value: record,
624 created,
625 })
626 }
627
628 pub async fn list(&self) -> Result<Vec<SessionRecord>, Error> {
629 list_session_summaries(self.state.clone())
630 .await
631 .map_err(Into::into)
632 }
633
634 pub async fn get(&self, id: &str) -> Result<SessionRecord, Error> {
635 get_session(self.state.clone(), id.to_owned())
636 .await
637 .map_err(Into::into)
638 }
639
640 pub async fn enqueue(
641 &self,
642 id: &str,
643 input: NewCommand,
644 ) -> Result<Created<SessionCommand>, Error> {
645 let (created, command) =
646 queue_session_command(self.state.clone(), id.to_owned(), input).await?;
647 Ok(Created {
648 value: command,
649 created,
650 })
651 }
652
653 pub async fn command_heads(&self) -> Result<Vec<SessionCommand>, Error> {
654 list_command_heads(self.state.clone())
655 .await
656 .map_err(Into::into)
657 }
658
659 pub async fn claim_command(&self, id: &str) -> Result<SessionCommand, Error> {
660 claim_command(self.state.clone(), id.to_owned())
661 .await
662 .map_err(Into::into)
663 }
664
665 pub async fn complete_command(
666 &self,
667 id: &str,
668 outcome: CommandOutcome,
669 ) -> Result<SessionCommand, Error> {
670 complete_command(self.state.clone(), id.to_owned(), outcome)
671 .await
672 .map_err(Into::into)
673 }
674
675 pub async fn request_stop(
676 &self,
677 id: &str,
678 input: NewStopRequest,
679 ) -> Result<Created<SessionStopRequest>, Error> {
680 let (created, request) = request_session_stop(
681 self.state.clone(),
682 id.to_owned(),
683 input.idempotency_id,
684 Some(input.scope),
685 )
686 .await?;
687 signal_stop_listener(&self.state, id).map_err(Error::from)?;
688 Ok(Created {
689 value: request,
690 created,
691 })
692 }
693
694 pub async fn request_current_work_stop(
702 &self,
703 id: &str,
704 input: NewCurrentWorkStop,
705 ) -> Result<Created<SessionStopRequest>, Error> {
706 let (created, request) = request_session_stop(
707 self.state.clone(),
708 id.to_owned(),
709 input.idempotency_id,
710 None,
711 )
712 .await?;
713 signal_stop_listener(&self.state, id).map_err(Error::from)?;
714 Ok(Created {
715 value: request,
716 created,
717 })
718 }
719
720 pub fn listen_for_stop(&self, id: &str) -> Result<StopListener, Error> {
725 listen_for_stop(&self.state, id).map_err(Into::into)
726 }
727
728 pub async fn stop_heads(&self) -> Result<Vec<SessionStopRequest>, Error> {
729 list_stop_heads(self.state.clone())
730 .await
731 .map_err(Into::into)
732 }
733
734 pub async fn complete_stop(
735 &self,
736 id: &str,
737 outcome: StopOutcome,
738 ) -> Result<SessionStopRequest, Error> {
739 complete_stop_request(self.state.clone(), id.to_owned(), outcome)
740 .await
741 .map_err(Into::into)
742 }
743
744 pub async fn stage_object(&self, id: &str, object: NewObject) -> Result<String, Error> {
745 stage_session_object(&self.state, id, object).map_err(Into::into)
746 }
747
748 pub fn object(&self, id: &str, pending_id: &str) -> Result<StoredObject, Error> {
749 get_session_object(&self.state, id, pending_id).map_err(Into::into)
750 }
751
752 pub async fn checkpoint(&self, id: &str, input: Checkpoint) -> Result<SessionRecord, Error> {
753 let record = checkpoint(
754 self.state.clone(),
755 id,
756 input.expected_version,
757 input.state,
758 input.user_activity,
759 )
760 .await?;
761 Ok(record)
762 }
763
764 pub async fn request_ingress(
765 &self,
766 id: &str,
767 input: Checkpoint,
768 ) -> Result<SessionRecord, Error> {
769 let record =
770 transition_with_checkpoint(self.state.clone(), id, input, "ingress_pending").await?;
771 Ok(record)
772 }
773
774 pub async fn start_ingress(
775 &self,
776 id: &str,
777 input: StartIngress,
778 ) -> Result<SessionRecord, Error> {
779 let record = transition(
780 self.state.clone(),
781 id,
782 input.expected_version,
783 "ingress_in_progress",
784 Some(input.provenance_id),
785 )
786 .await?;
787 Ok(record)
788 }
789
790 pub async fn complete_ingress(
791 &self,
792 id: &str,
793 input: ExpectedVersion,
794 ) -> Result<SessionRecord, Error> {
795 let current = fetch_active(&self.state, id)?;
796 let record = complete_session(
797 self.state.clone(),
798 id,
799 input.expected_version,
800 current.state,
801 )
802 .await?;
803 Ok(record)
804 }
805
806 pub async fn fail_ingress(
807 &self,
808 id: &str,
809 input: IngressFailure,
810 ) -> Result<SessionRecord, Error> {
811 let record = record_ingress_failure(self.state.clone(), id, input).await?;
812 Ok(record)
813 }
814
815 pub async fn retry_ingress(
816 &self,
817 id: &str,
818 input: RetryIngress,
819 ) -> Result<SessionRecord, Error> {
820 retry_ingress(self.state.clone(), id.to_owned(), input)
821 .await
822 .map_err(Into::into)
823 }
824
825 pub async fn release_interrupted_ingress(&self) -> Result<Vec<String>, Error> {
826 release_ingress_repairs(self.state.clone())
827 .await
828 .map_err(Into::into)
829 }
830
831 pub async fn complete(&self, id: &str, input: Checkpoint) -> Result<SessionRecord, Error> {
832 let record =
833 complete_session(self.state.clone(), id, input.expected_version, input.state).await?;
834 Ok(record)
835 }
836
837 pub async fn record_completion(&self, input: RecordCompletion) -> Result<(), Error> {
838 record_completed_session(self.state.clone(), input).await?;
839 Ok(())
840 }
841}
842
843fn is_metadata_free_session_log_archive(archive: &Value) -> bool {
844 if archive.get("metadata").is_some() {
845 return false;
846 }
847 let Some(header) = archive.get("header") else {
848 return false;
849 };
850 if header.get("formatVersion").and_then(Value::as_str)
851 != Some(kcode_session_log::FORMAT_VERSION)
852 || !header
853 .get("sessionId")
854 .and_then(Value::as_str)
855 .is_some_and(|value| !value.trim().is_empty())
856 || !header
857 .get("createdAt")
858 .and_then(Value::as_str)
859 .is_some_and(|value| !value.trim().is_empty())
860 {
861 return false;
862 }
863 archive
864 .get("events")
865 .and_then(Value::as_array)
866 .is_some_and(|events| {
867 events.iter().all(|event| {
868 event.get("text").and_then(Value::as_str).is_some()
869 && event
870 .get("role")
871 .and_then(Value::as_str)
872 .is_some_and(|role| {
873 matches!(
874 role,
875 "system-message"
876 | "system-error"
877 | "user-message"
878 | "kennedy-message"
879 | "kennedy-tool-call"
880 | "tool-result"
881 | "tool-error"
882 | "object"
883 | "pending-object"
884 )
885 })
886 })
887 })
888}
889
890async fn record_completed_session(
891 state: AppState,
892 input: RecordCompletion,
893) -> Result<Value, ApiError> {
894 let session_guard = input
895 .session_id
896 .as_deref()
897 .map(|id| session_mutation(&state, id))
898 .transpose()?;
899 let _session_guard = session_guard
900 .as_ref()
901 .map(|guard| guard.lock().map_err(ApiError::internal))
902 .transpose()?;
903 let _catalog_guard = state.catalog_mutation.lock().map_err(ApiError::internal)?;
904 let mut receipt = input.commit_receipt.unwrap_or(CompletionReceipt {
905 transaction_id: None,
906 session_object_id: input.session_object_id.clone(),
907 session_id: None,
908 session_type: None,
909 created_at: None,
910 committed_at: None,
911 ingress_source: None,
912 node_ids: BTreeMap::new(),
913 object_ids: BTreeMap::new(),
914 });
915 if receipt.session_object_id != input.session_object_id {
916 return Err(ApiError::conflict(
917 "completion receipt and requested session object differ",
918 ));
919 }
920 receipt.session_id = receipt.session_id.or(input.session_id.clone());
921 receipt.session_type = receipt.session_type.or(input.session_type);
922 receipt.created_at = receipt.created_at.or(input.created_at);
923 receipt.committed_at.get_or_insert_with(now);
924 append_completion_receipt(&state.config.completed_list, &receipt)
925 .map_err(ApiError::internal)?;
926 if let Some(id) = input.session_id {
927 validate_session_id(&id)?;
928 let path = state.config.directory.join(format!("{id}.session-log"));
929 if path.exists() {
930 SessionJournal::open(&path)
931 .map_err(ApiError::internal)?
932 .log
933 .delete_committed()
934 .map_err(ApiError::internal)?;
935 let control = control_path(&state.config.directory, &id);
936 if control.exists() {
937 std::fs::remove_file(&control)
938 .with_context(|| format!("removing {}", control.display()))
939 .map_err(ApiError::internal)?;
940 sync_directory(&state.config.directory).map_err(ApiError::internal)?;
941 }
942 }
943 }
944 Ok(json!({
945 "sessionObjectId":input.session_object_id,
946 "recorded":true
947 }))
948}
949
950async fn create_session(
951 state: AppState,
952 input: RegisterSession,
953) -> Result<SessionRecord, ApiError> {
954 validate_started_at(&input.started_at)?;
955 validate_session_id(&input.id)?;
956 let path = state
957 .config
958 .directory
959 .join(format!("{}.session-log", input.id));
960 let journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
961 let id = journal.list().header.session_id;
962 if id != input.id {
963 return Err(ApiError::bad(
964 "registered session ID does not match its durable session log",
965 ));
966 }
967 drop(journal);
968 let session_guard = session_mutation(&state, &id)?;
969 let _guard = session_guard.lock().map_err(ApiError::internal)?;
970 let mut journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
971 if latest_lifecycle(&journal).is_some() {
972 return Err(ApiError::conflict("Session is already registered."));
973 }
974 let record = SessionRecord {
975 id,
976 phase: "active".into(),
977 started_at: input.started_at.clone(),
978 updated_at: input.started_at,
979 state: control_state(&input.state),
980 provenance_id: None,
981 version: 1,
982 last_user_message_at: None,
983 ended_at: None,
984 ingress_failure_count: 0,
985 ingress_failures: json!([]),
986 ingress_next_attempt_at: None,
987 summary: false,
988 };
989 append_lifecycle(&mut journal, &record)?;
990 Ok(materialize(
991 record,
992 &journal,
993 state.config.provider_cost_compatibility,
994 ))
995}
996
997async fn start_managed_session(
998 state: AppState,
999 input: StartSession,
1000) -> Result<(bool, SessionRecord), ApiError> {
1001 validate_started_at(&input.started_at)?;
1002 validate_idempotency(&input.idempotency_id)?;
1003 let _catalog_guard = state.catalog_mutation.lock().map_err(ApiError::internal)?;
1004 for path in journal_paths(&state.config.directory)? {
1005 let journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
1006 if let Some(record) = latest_lifecycle(&journal)
1007 && record
1008 .state
1009 .get("startIdempotencyId")
1010 .and_then(Value::as_str)
1011 == Some(&input.idempotency_id)
1012 {
1013 return Ok((
1014 false,
1015 materialize(record, &journal, state.config.provider_cost_compatibility),
1016 ));
1017 }
1018 }
1019 let id = Uuid::new_v4().to_string();
1020 let mut journal = SessionJournal::create(&state.config.directory, &id, &input.started_at)
1021 .map_err(ApiError::internal)?;
1022 let mut session_state = json!({
1023 "stateVersion":3,
1024 "sessionId":id,
1025 "sessionType":input.session_type,
1026 "startedAt":input.started_at,
1027 "startIdempotencyId":input.idempotency_id,
1028 "orchestration":{"owner":"backend","status":"idle"},
1029 });
1030 if input.session_type == "free-time" {
1031 session_state["selfTimeIntent"] = json!({
1032 "requestedAt":input.started_at,
1033 "durationMinutes":input.duration_minutes,
1034 "customPrompt":input.custom_prompt.unwrap_or_default(),
1035 });
1036 }
1037 let record = SessionRecord {
1038 id,
1039 phase: "active".into(),
1040 started_at: input.started_at.clone(),
1041 updated_at: input.started_at,
1042 state: session_state,
1043 provenance_id: None,
1044 version: 1,
1045 last_user_message_at: None,
1046 ended_at: None,
1047 ingress_failure_count: 0,
1048 ingress_failures: json!([]),
1049 ingress_next_attempt_at: None,
1050 summary: false,
1051 };
1052 append_lifecycle(&mut journal, &record)?;
1053 Ok((
1054 true,
1055 materialize(record, &journal, state.config.provider_cost_compatibility),
1056 ))
1057}
1058
1059async fn enqueue_ingress_session(
1060 state: AppState,
1061 input: NewIngressSession,
1062) -> Result<(bool, SessionRecord), ApiError> {
1063 validate_started_at(&input.started_at)?;
1064 validate_idempotency(&input.idempotency_id)?;
1065 if input.source_session_type.trim().is_empty() {
1066 return Err(ApiError::bad("Source session type must not be empty."));
1067 }
1068 if input.text.trim().is_empty() {
1069 return Err(ApiError::bad("Ingress source text must not be empty."));
1070 }
1071 let _catalog_guard = state.catalog_mutation.lock().map_err(ApiError::internal)?;
1072 for path in journal_paths(&state.config.directory)? {
1073 let journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
1074 if let Some(record) = latest_lifecycle(&journal)
1075 && ingress_idempotency_id(record.state.get("ingressSource"))
1076 == Some(input.idempotency_id.as_str())
1077 {
1078 return Ok((
1079 false,
1080 materialize(record, &journal, state.config.provider_cost_compatibility),
1081 ));
1082 }
1083 }
1084 for receipt in
1085 read_completion_receipts(&state.config.completed_list).map_err(ApiError::internal)?
1086 {
1087 if ingress_idempotency_id(receipt.ingress_source.as_ref())
1088 == Some(input.idempotency_id.as_str())
1089 {
1090 return Ok((false, completed_session_record(receipt, false)));
1091 }
1092 }
1093
1094 let id = Uuid::new_v4().to_string();
1095 let path = state.config.directory.join(format!("{id}.session-log"));
1096 let mut source = Session::create(
1097 &path,
1098 chatend::SessionMetadata {
1099 session_id: id.clone(),
1100 kind: input.kind,
1101 created_at: input.started_at.clone(),
1102 effective_context_tokens: input.effective_context_tokens,
1103 channel: Value::Null,
1104 },
1105 )
1106 .map_err(ApiError::internal)?;
1107 source
1108 .create_box(
1109 input.started_at.clone(),
1110 "Ingress source",
1111 chatend::BoxOwner::User,
1112 chatend::BoxContent {
1113 text: input.text.trim().to_owned(),
1114 objects: Vec::new(),
1115 metadata: input.metadata.clone(),
1116 },
1117 )
1118 .map_err(ApiError::internal)?;
1119 let chatend_metadata = source.state().metadata.clone();
1120 drop(source);
1121
1122 let ingress_source = json!({
1123 "idempotencyId":input.idempotency_id,
1124 "metadata":input.metadata,
1125 });
1126 let mut journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
1127 let record = SessionRecord {
1128 id: id.clone(),
1129 phase: "ingress_pending".into(),
1130 started_at: input.started_at.clone(),
1131 updated_at: input.started_at.clone(),
1132 state: json!({
1133 "stateVersion":3,
1134 "sessionId":id,
1135 "sessionType":input.source_session_type,
1136 "startedAt":input.started_at,
1137 "ingressSource":ingress_source,
1138 "chatendMetadata":chatend_metadata,
1139 }),
1140 provenance_id: None,
1141 version: 1,
1142 last_user_message_at: None,
1143 ended_at: None,
1144 ingress_failure_count: 0,
1145 ingress_failures: json!([]),
1146 ingress_next_attempt_at: None,
1147 summary: false,
1148 };
1149 append_lifecycle(&mut journal, &record)?;
1150 Ok((
1151 true,
1152 materialize(record, &journal, state.config.provider_cost_compatibility),
1153 ))
1154}
1155
1156fn ingress_idempotency_id(source: Option<&Value>) -> Option<&str> {
1157 source?.get("idempotencyId").and_then(Value::as_str)
1158}
1159
1160async fn list_session_summaries(state: AppState) -> Result<Vec<SessionRecord>, ApiError> {
1161 let mut sessions = Vec::new();
1162 for journal in open_listed_journals(journal_paths(&state.config.directory)?)? {
1163 if let Some(mut record) = latest_lifecycle(&journal) {
1164 record.summary = true;
1165 record.state = summary_state(&record.state, &journal);
1166 sessions.push(record);
1167 }
1168 }
1169 let receipts =
1170 read_completion_receipts(&state.config.completed_list).map_err(ApiError::internal)?;
1171 let completed_session_ids = receipts
1172 .iter()
1173 .filter_map(|receipt| receipt.session_id.as_ref())
1174 .collect::<HashSet<_>>();
1175 sessions.retain(|session| !completed_session_ids.contains(&session.id));
1176 for receipt in receipts {
1177 sessions.push(completed_session_record(receipt, true));
1178 }
1179 sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
1180 Ok(sessions)
1181}
1182
1183async fn get_session(state: AppState, id: String) -> Result<SessionRecord, ApiError> {
1184 if let Some(receipt) = read_completion_receipts(&state.config.completed_list)
1185 .map_err(ApiError::internal)?
1186 .into_iter()
1187 .find(|receipt| receipt.session_object_id == id)
1188 {
1189 return Ok(completed_session_record(receipt, false));
1190 }
1191 let journal = open_by_id(&state, &id)?;
1192 let record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1193 Ok(materialize(
1194 record,
1195 &journal,
1196 state.config.provider_cost_compatibility,
1197 ))
1198}
1199
1200fn completed_session_record(receipt: CompletionReceipt, summary: bool) -> SessionRecord {
1201 let object_id = receipt.session_object_id.clone();
1202 let started_at = receipt.created_at.clone().unwrap_or_default();
1203 let updated_at = receipt.committed_at.clone().unwrap_or_default();
1204 SessionRecord {
1205 id: object_id.clone(),
1206 phase: "complete".into(),
1207 started_at,
1208 updated_at,
1209 state: json!({
1210 "sessionObjectId":object_id,
1211 "sessionId":receipt.session_id.clone(),
1212 "sessionType":receipt.session_type.clone(),
1213 "ingressSource":receipt.ingress_source.clone(),
1214 "commitReceipt":receipt,
1215 }),
1216 provenance_id: None,
1217 version: 1,
1218 last_user_message_at: None,
1219 ended_at: None,
1220 ingress_failure_count: 0,
1221 ingress_failures: json!([]),
1222 ingress_next_attempt_at: None,
1223 summary,
1224 }
1225}
1226
1227fn get_session_object(
1228 state: &AppState,
1229 id: &str,
1230 pending_id: &str,
1231) -> Result<StoredObject, ApiError> {
1232 let number = pending_id
1233 .strip_prefix("pending:")
1234 .and_then(|value| value.parse::<u64>().ok())
1235 .filter(|value| *value > 0)
1236 .ok_or_else(|| ApiError::bad("Object ID must have the form pending:N."))?;
1237 let journal = open_by_id(state, id)?;
1238 let object = journal
1239 .log
1240 .read_pending_object(EventPosition(number - 1))
1241 .map_err(|error| {
1242 tracing::warn!(session_id=id, pending_id, %error, "pending session object is unavailable");
1243 ApiError::not_found()
1244 })?;
1245 let media_type = if object.media_type.trim().is_empty()
1246 || object
1247 .media_type
1248 .chars()
1249 .any(|character| character.is_control() || character.is_whitespace())
1250 || !object.media_type.contains('/')
1251 {
1252 "application/octet-stream"
1253 } else {
1254 &object.media_type
1255 };
1256 let mut file_name = object
1257 .file_name
1258 .chars()
1259 .map(|character| {
1260 if character.is_ascii_graphic() && !matches!(character, '"' | '\\' | '/' | ';') {
1261 character
1262 } else {
1263 '_'
1264 }
1265 })
1266 .take(255)
1267 .collect::<String>();
1268 if file_name.is_empty() {
1269 file_name = "uploaded-object".into();
1270 }
1271 Ok(StoredObject {
1272 file_name,
1273 media_type: media_type.to_owned(),
1274 bytes: object.bytes,
1275 })
1276}
1277
1278async fn queue_session_command(
1279 state: AppState,
1280 id: String,
1281 input: NewCommand,
1282) -> Result<(bool, SessionCommand), ApiError> {
1283 validate_idempotency(&input.idempotency_id)?;
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 record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1288 if record.phase != "active" {
1289 return Err(ApiError::conflict("Session is no longer active."));
1290 }
1291 let commands = commands(&journal);
1292 if let Some(command) = commands
1293 .values()
1294 .find(|command| command.idempotency_id == input.idempotency_id)
1295 {
1296 return Ok((false, command.clone()));
1297 }
1298 let command = SessionCommand {
1299 id: Uuid::new_v4().to_string(),
1300 conversation_id: id,
1301 sequence: commands
1302 .values()
1303 .map(|command| command.sequence)
1304 .max()
1305 .unwrap_or(0)
1306 + 1,
1307 kind: input.kind,
1308 payload: input.payload,
1309 status: "pending".into(),
1310 cancel_requested: false,
1311 outcome: None,
1312 created_at: now(),
1313 processing_started_at: None,
1314 completed_at: None,
1315 idempotency_id: input.idempotency_id,
1316 };
1317 append_command(&mut journal, &command)?;
1318 Ok((true, command))
1319}
1320
1321fn stage_session_object(state: &AppState, id: &str, object: NewObject) -> Result<String, ApiError> {
1322 let NewObject {
1323 file_name,
1324 media_type,
1325 bytes,
1326 } = object;
1327 let session_guard = session_mutation(state, id)?;
1328 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1329 let mut journal = open_by_id(state, id)?;
1330 let record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1331 if record.phase != "active" {
1332 return Err(ApiError::conflict(
1333 "Objects can only be supplied to an active session.",
1334 ));
1335 }
1336 if commands(&journal)
1337 .values()
1338 .any(|command| matches!(command.status.as_str(), "pending" | "processing"))
1339 {
1340 return Err(ApiError::conflict(
1341 "Objects cannot be supplied while the session is processing a command.",
1342 ));
1343 }
1344 let pending_id = journal
1345 .stage_object(media_type, file_name, &bytes)
1346 .map_err(|error| ApiError::bad(error.to_string()))?;
1347 Ok(pending_id)
1348}
1349
1350async fn list_command_heads(state: AppState) -> Result<Vec<SessionCommand>, ApiError> {
1351 let mut heads = Vec::new();
1352 for journal in open_listed_journals(journal_paths(&state.config.directory)?)? {
1353 let mut active = commands(&journal)
1354 .into_values()
1355 .filter(|command| matches!(command.status.as_str(), "pending" | "processing"))
1356 .collect::<Vec<_>>();
1357 active.sort_by_key(|command| command.sequence);
1358 if let Some(head) = active.into_iter().next() {
1359 heads.push(head);
1360 }
1361 }
1362 heads.sort_by(|a, b| a.created_at.cmp(&b.created_at));
1363 Ok(heads)
1364}
1365
1366async fn claim_command(state: AppState, command_id: String) -> Result<SessionCommand, ApiError> {
1367 mutate_command(&state, &command_id, |command| {
1368 if command.status == "pending" {
1369 command.status = "processing".into();
1370 command.processing_started_at = Some(now());
1371 } else if command.status != "processing" {
1372 return Err(ApiError::conflict("Command is already complete."));
1373 }
1374 Ok(())
1375 })
1376}
1377
1378async fn complete_command(
1379 state: AppState,
1380 command_id: String,
1381 input: CommandOutcome,
1382) -> Result<SessionCommand, ApiError> {
1383 mutate_command(&state, &command_id, |command| {
1384 if command.status == "complete" {
1385 return Ok(());
1386 }
1387 if command.status != "processing" {
1388 return Err(ApiError::conflict("Command was not claimed."));
1389 }
1390 command.status = "complete".into();
1391 command.outcome = Some(input.outcome);
1392 command.completed_at = Some(now());
1393 Ok(())
1394 })
1395}
1396
1397async fn request_session_stop(
1398 state: AppState,
1399 id: String,
1400 idempotency_id: String,
1401 requested_scope: Option<String>,
1402) -> Result<(bool, SessionStopRequest), ApiError> {
1403 validate_idempotency(&idempotency_id)?;
1404 if requested_scope
1405 .as_deref()
1406 .is_some_and(|scope| !matches!(scope, "turn" | "session" | "self-time-run"))
1407 {
1408 return Err(ApiError::bad(
1409 "Stop scope must be turn, session, or self-time-run.",
1410 ));
1411 }
1412 let session_guard = session_mutation(&state, &id)?;
1413 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1414 let mut journal = open_by_id(&state, &id)?;
1415 let lifecycle = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1416 if !matches!(
1417 lifecycle.phase.as_str(),
1418 "active" | "ingress_pending" | "ingress_in_progress"
1419 ) {
1420 return Err(ApiError::conflict("Session has no work in progress."));
1421 }
1422 let existing = stop_requests(&journal);
1423 if let Some(request) = existing
1424 .values()
1425 .find(|request| request.idempotency_id == idempotency_id)
1426 {
1427 return Ok((false, request.clone()));
1428 }
1429 if let Some(request) = existing
1430 .values()
1431 .find(|request| request.status == "pending")
1432 {
1433 return Ok((false, request.clone()));
1434 }
1435 let scope = match requested_scope {
1436 Some(scope) => scope,
1437 None => current_work_stop_scope(&lifecycle)?.into(),
1438 };
1439 let request = SessionStopRequest {
1440 id: Uuid::new_v4().to_string(),
1441 session_id: id,
1442 scope,
1443 status: "pending".into(),
1444 outcome: None,
1445 requested_at: now(),
1446 completed_at: None,
1447 idempotency_id,
1448 };
1449 append_stop_request(&mut journal, &request)?;
1450
1451 if request.scope == "turn" {
1452 let mut active = commands(&journal)
1453 .into_values()
1454 .filter(|command| {
1455 matches!(command.status.as_str(), "pending" | "processing")
1456 && matches!(command.kind.as_str(), "message" | "retry")
1457 })
1458 .collect::<Vec<_>>();
1459 active.sort_by_key(|command| command.sequence);
1460 if let Some(mut command) = active.into_iter().next() {
1461 command.cancel_requested = true;
1462 append_command(&mut journal, &command)?;
1463 }
1464 }
1465 Ok((true, request))
1466}
1467
1468fn current_work_stop_scope(record: &SessionRecord) -> Result<&'static str, ApiError> {
1469 if record.phase != "active" {
1470 return Ok("session");
1471 }
1472 let kind = record
1473 .state
1474 .get("chatendMetadata")
1475 .cloned()
1476 .and_then(|value| serde_json::from_value::<chatend::SessionMetadata>(value).ok())
1477 .map(|metadata| metadata.kind);
1478 match kind {
1479 Some(
1480 chatend::SessionKind::Conversation
1481 | chatend::SessionKind::Telegram
1482 | chatend::SessionKind::TelegramGroup,
1483 ) => Ok("turn"),
1484 Some(chatend::SessionKind::SelfTime) => Ok("self-time-run"),
1485 Some(_) => Ok("session"),
1486 None => match record.state.get("sessionType").and_then(Value::as_str) {
1487 Some("conversation" | "telegram" | "telegram-group") => Ok("turn"),
1488 Some("free-time") => Ok("self-time-run"),
1489 Some(_) => Ok("session"),
1490 None => Err(ApiError::conflict(
1491 "Session does not identify the work that should stop.",
1492 )),
1493 },
1494 }
1495}
1496
1497fn listen_for_stop(state: &AppState, id: &str) -> Result<StopListener, ApiError> {
1498 let session_guard = session_mutation(state, id)?;
1499 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1500 let journal = open_by_id(state, id)?;
1501 let signal = {
1502 let mut listeners = state.stop_listeners.lock().map_err(ApiError::internal)?;
1503 listeners.retain(|_, listener| listener.strong_count() > 0);
1504 if let Some(signal) = listeners.get(id).and_then(Weak::upgrade) {
1505 signal
1506 } else {
1507 let signal = Arc::new(StopSignal {
1508 requested: AtomicBool::new(false),
1509 notification: Notify::new(),
1510 });
1511 listeners.insert(id.to_owned(), Arc::downgrade(&signal));
1512 signal
1513 }
1514 };
1515 if stop_requests(&journal)
1516 .values()
1517 .any(|request| request.status == "pending")
1518 {
1519 signal_stop(&signal);
1520 }
1521 Ok(StopListener { signal })
1522}
1523
1524fn signal_stop_listener(state: &AppState, id: &str) -> Result<(), ApiError> {
1525 let signal = state
1526 .stop_listeners
1527 .lock()
1528 .map_err(ApiError::internal)?
1529 .get(id)
1530 .and_then(Weak::upgrade);
1531 if let Some(signal) = signal {
1532 signal_stop(&signal);
1533 }
1534 Ok(())
1535}
1536
1537fn signal_stop(signal: &StopSignal) {
1538 signal.requested.store(true, Ordering::Release);
1539 signal.notification.notify_waiters();
1540}
1541
1542async fn list_stop_heads(state: AppState) -> Result<Vec<SessionStopRequest>, ApiError> {
1543 let mut heads = Vec::new();
1544 for journal in open_listed_journals(journal_paths(&state.config.directory)?)? {
1545 if let Some(request) = stop_requests(&journal)
1546 .into_values()
1547 .find(|request| request.status == "pending")
1548 {
1549 heads.push(request);
1550 }
1551 }
1552 heads.sort_by(|left, right| left.requested_at.cmp(&right.requested_at));
1553 Ok(heads)
1554}
1555
1556async fn complete_stop_request(
1557 state: AppState,
1558 request_id: String,
1559 input: StopOutcome,
1560) -> Result<SessionStopRequest, ApiError> {
1561 mutate_stop_request(&state, &request_id, |request| {
1562 if request.status == "complete" {
1563 return Ok(());
1564 }
1565 request.status = "complete".into();
1566 request.outcome = Some(input.outcome);
1567 request.completed_at = Some(now());
1568 Ok(())
1569 })
1570}
1571
1572fn mutate_command(
1573 state: &AppState,
1574 command_id: &str,
1575 mutation: impl FnOnce(&mut SessionCommand) -> Result<(), ApiError>,
1576) -> Result<SessionCommand, ApiError> {
1577 let mut target = None;
1578 for path in journal_paths(&state.config.directory)? {
1579 let journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
1580 if let Some(command) = commands(&journal).remove(command_id) {
1581 target = Some((path, command.conversation_id));
1582 break;
1583 }
1584 }
1585 let (path, conversation_id) = target.ok_or_else(ApiError::not_found)?;
1586 let session_guard = session_mutation(state, &conversation_id)?;
1587 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1588 let mut journal = SessionJournal::open(path).map_err(ApiError::internal)?;
1589 let mut command = commands(&journal)
1590 .remove(command_id)
1591 .ok_or_else(ApiError::not_found)?;
1592 mutation(&mut command)?;
1593 append_command(&mut journal, &command)?;
1594 Ok(command)
1595}
1596
1597fn mutate_stop_request(
1598 state: &AppState,
1599 request_id: &str,
1600 mutation: impl FnOnce(&mut SessionStopRequest) -> Result<(), ApiError>,
1601) -> Result<SessionStopRequest, ApiError> {
1602 let mut target = None;
1603 for path in journal_paths(&state.config.directory)? {
1604 let journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
1605 if let Some(request) = stop_requests(&journal).remove(request_id) {
1606 target = Some((path, request.session_id));
1607 break;
1608 }
1609 }
1610 let (path, session_id) = target.ok_or_else(ApiError::not_found)?;
1611 let session_guard = session_mutation(state, &session_id)?;
1612 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1613 let mut journal = SessionJournal::open(path).map_err(ApiError::internal)?;
1614 let mut request = stop_requests(&journal)
1615 .remove(request_id)
1616 .ok_or_else(ApiError::not_found)?;
1617 mutation(&mut request)?;
1618 append_stop_request(&mut journal, &request)?;
1619 Ok(request)
1620}
1621
1622async fn checkpoint(
1623 state: AppState,
1624 id: &str,
1625 expected_version: i64,
1626 new_state: Value,
1627 user_activity: bool,
1628) -> Result<SessionRecord, ApiError> {
1629 let session_guard = session_mutation(&state, id)?;
1630 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1631 let mut journal = open_by_id(&state, id)?;
1632 let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1633 require_version(&record, expected_version)?;
1634 record.state = control_state(&new_state);
1635 record.version += 1;
1636 record.updated_at = now();
1637 if user_activity {
1638 record.last_user_message_at = Some(record.updated_at.clone());
1639 }
1640 append_lifecycle(&mut journal, &record)?;
1641 Ok(materialize(
1642 record,
1643 &journal,
1644 state.config.provider_cost_compatibility,
1645 ))
1646}
1647
1648async fn transition_with_checkpoint(
1649 state: AppState,
1650 id: &str,
1651 input: Checkpoint,
1652 phase: &str,
1653) -> Result<SessionRecord, ApiError> {
1654 let session_guard = session_mutation(&state, id)?;
1655 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1656 let mut journal = open_by_id(&state, id)?;
1657 let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1658 require_version(&record, input.expected_version)?;
1659 record.state = control_state(&input.state);
1660 record.phase = phase.into();
1661 if phase == "ingress_pending" {
1662 record.ingress_next_attempt_at = None;
1663 }
1664 record.version += 1;
1665 record.updated_at = now();
1666 append_lifecycle(&mut journal, &record)?;
1667 Ok(materialize(
1668 record,
1669 &journal,
1670 state.config.provider_cost_compatibility,
1671 ))
1672}
1673
1674async fn transition(
1675 state: AppState,
1676 id: &str,
1677 expected_version: i64,
1678 phase: &str,
1679 provenance_id: Option<String>,
1680) -> Result<SessionRecord, ApiError> {
1681 let session_guard = session_mutation(&state, id)?;
1682 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1683 let mut journal = open_by_id(&state, id)?;
1684 let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1685 require_version(&record, expected_version)?;
1686 record.phase = phase.into();
1687 record.provenance_id = provenance_id;
1688 if phase == "ingress_in_progress" {
1689 record.ingress_next_attempt_at = None;
1690 }
1691 record.version += 1;
1692 record.updated_at = now();
1693 append_lifecycle(&mut journal, &record)?;
1694 Ok(materialize(
1695 record,
1696 &journal,
1697 state.config.provider_cost_compatibility,
1698 ))
1699}
1700
1701async fn record_ingress_failure(
1702 state: AppState,
1703 id: &str,
1704 input: IngressFailure,
1705) -> Result<SessionRecord, ApiError> {
1706 let session_guard = session_mutation(&state, id)?;
1707 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1708 let mut journal = open_by_id(&state, id)?;
1709 let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1710 require_version(&record, input.expected_version)?;
1711 if !matches!(
1712 record.phase.as_str(),
1713 "ingress_pending" | "ingress_in_progress"
1714 ) {
1715 return Err(ApiError::conflict(
1716 "Session History ingress is not in an active attempt.",
1717 ));
1718 }
1719 let attempt = record.ingress_failure_count.saturating_add(1);
1720 let terminal =
1721 input.code.as_deref() == Some("input_too_large") || attempt >= INGRESS_FAILURE_LIMIT;
1722 let mut failures = record
1723 .ingress_failures
1724 .as_array()
1725 .cloned()
1726 .unwrap_or_default();
1727 failures.push(json!({
1728 "attempt":attempt,
1729 "at":now(),
1730 "stage":input.stage,
1731 "code":input.code,
1732 "message":input.message,
1733 "roundsUsed":input.rounds_used,
1734 "contextTokens":input.context_tokens,
1735 "contextWindowTokens":input.context_window_tokens,
1736 }));
1737 if failures.len() > RETAINED_INGRESS_FAILURES {
1738 failures.drain(..failures.len() - RETAINED_INGRESS_FAILURES);
1739 }
1740 record.ingress_failures = Value::Array(failures);
1741 record.ingress_failure_count = attempt;
1742 record.phase = if terminal {
1743 "ingress_failed".into()
1744 } else {
1745 "ingress_pending".into()
1746 };
1747 let updated_at = now();
1748 record.ingress_next_attempt_at = (!terminal)
1749 .then(|| (Utc::now() + Duration::seconds(INGRESS_RETRY_DELAY_SECONDS)).to_rfc3339());
1750 record.version += 1;
1751 record.updated_at = updated_at;
1752 append_lifecycle(&mut journal, &record)?;
1753 if terminal {
1754 tracing::error!(
1755 session_id = id,
1756 attempt,
1757 stage = %input.stage,
1758 code = input.code.as_deref().unwrap_or("ingress_error"),
1759 terminal_reason = if input.code.as_deref() == Some("input_too_large") {
1760 "non_retryable"
1761 } else {
1762 "retry_limit"
1763 },
1764 "Session History ingress stopped after a terminal failure"
1765 );
1766 }
1767 Ok(materialize(
1768 record,
1769 &journal,
1770 state.config.provider_cost_compatibility,
1771 ))
1772}
1773
1774async fn retry_ingress(
1775 state: AppState,
1776 id: String,
1777 input: RetryIngress,
1778) -> Result<SessionRecord, ApiError> {
1779 let session_guard = session_mutation(&state, &id)?;
1780 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1781 let mut journal = open_by_id(&state, &id)?;
1782 let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1783 require_version(&record, input.expected_version)?;
1784 if record.phase != "ingress_failed" {
1785 return Err(ApiError::conflict(
1786 "Session History ingress is not in the failed state.",
1787 ));
1788 }
1789 record.state = control_state(&input.state);
1790 record.phase = "ingress_pending".into();
1791 record.ingress_failure_count = 0;
1792 record.ingress_next_attempt_at = None;
1793 record.version += 1;
1794 record.updated_at = now();
1795 append_lifecycle(&mut journal, &record)?;
1796 Ok(materialize(
1797 record,
1798 &journal,
1799 state.config.provider_cost_compatibility,
1800 ))
1801}
1802
1803async fn release_ingress_repairs(state: AppState) -> Result<Vec<String>, ApiError> {
1804 let mut released = Vec::new();
1805 for path in journal_paths(&state.config.directory)? {
1806 let Some(id) = path
1807 .file_stem()
1808 .and_then(|value| value.to_str())
1809 .map(str::to_owned)
1810 else {
1811 continue;
1812 };
1813 let session_guard = session_mutation(&state, &id)?;
1814 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1815 let mut journal = open_by_id(&state, &id)?;
1816 let Some(mut record) = latest_lifecycle(&journal) else {
1817 continue;
1818 };
1819 if record.phase != "ingress_in_progress" {
1820 continue;
1821 }
1822 record.phase = "ingress_pending".into();
1823 record.ingress_next_attempt_at = None;
1824 if record.ingress_failure_count > INGRESS_FAILURE_LIMIT {
1825 record.ingress_failure_count = 0;
1826 }
1827 if let Some(failures) = record.ingress_failures.as_array_mut()
1828 && failures.len() > RETAINED_INGRESS_FAILURES
1829 {
1830 failures.drain(..failures.len() - RETAINED_INGRESS_FAILURES);
1831 }
1832 record.version += 1;
1833 record.updated_at = now();
1834 append_lifecycle(&mut journal, &record)?;
1835 released.push(id);
1836 }
1837 Ok(released)
1838}
1839
1840async fn complete_session(
1841 state: AppState,
1842 id: &str,
1843 expected_version: i64,
1844 new_state: Value,
1845) -> Result<SessionRecord, ApiError> {
1846 let session_guard = session_mutation(&state, id)?;
1847 let _guard = session_guard.lock().map_err(ApiError::internal)?;
1848 let journal = open_by_id(&state, id)?;
1849 let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1850 require_version(&record, expected_version)?;
1851 let completion_state = new_state
1852 .get("historyIngress")
1853 .filter(|state| state.get("sessionObjectId").is_some_and(Value::is_string))
1854 .cloned()
1855 .unwrap_or_else(|| new_state.clone());
1856 record.state = control_state(&new_state);
1857 let object_id = completion_state
1858 .get("sessionObjectId")
1859 .and_then(Value::as_str)
1860 .ok_or_else(|| {
1861 ApiError::conflict("completed session has no permanent Kweb session object")
1862 })?
1863 .to_owned();
1864 let _catalog_guard = state.catalog_mutation.lock().map_err(ApiError::internal)?;
1865 let mut receipt = completion_state
1866 .get("commitReceipt")
1867 .filter(|receipt| !receipt.is_null())
1868 .cloned()
1869 .map(serde_json::from_value::<CompletionReceipt>)
1870 .transpose()
1871 .map_err(|error| ApiError::conflict(format!("invalid session commit receipt: {error}")))?
1872 .unwrap_or(CompletionReceipt {
1873 transaction_id: None,
1874 session_object_id: object_id.clone(),
1875 session_id: None,
1876 session_type: None,
1877 created_at: None,
1878 committed_at: None,
1879 ingress_source: None,
1880 node_ids: BTreeMap::new(),
1881 object_ids: BTreeMap::new(),
1882 });
1883 if receipt.session_object_id != object_id {
1884 return Err(ApiError::conflict(
1885 "session commit receipt names a different archive object",
1886 ));
1887 }
1888 let completed_at = now();
1889 receipt.session_id = Some(record.id.clone());
1890 receipt.session_type = record
1891 .state
1892 .get("sessionType")
1893 .and_then(Value::as_str)
1894 .map(str::to_owned);
1895 receipt.created_at = Some(record.started_at.clone());
1896 receipt.committed_at = Some(completed_at.clone());
1897 receipt.ingress_source = record.state.get("ingressSource").cloned();
1898 append_completion_receipt(&state.config.completed_list, &receipt)
1899 .map_err(ApiError::internal)?;
1900 record.phase = "complete".into();
1901 record.version += 1;
1902 record.updated_at = completed_at;
1903 record.ended_at = Some(record.updated_at.clone());
1904 record.state["sessionObjectId"] = json!(object_id);
1905 record.state["commitReceipt"] = completion_state
1906 .get("commitReceipt")
1907 .cloned()
1908 .unwrap_or(Value::Null);
1909 let output = materialize(record, &journal, state.config.provider_cost_compatibility);
1910 let control_path = control_path(&state.config.directory, id);
1911 journal.log.delete_committed().map_err(ApiError::internal)?;
1912 if control_path.exists() {
1913 std::fs::remove_file(&control_path)
1914 .with_context(|| format!("removing {}", control_path.display()))
1915 .map_err(ApiError::internal)?;
1916 sync_directory(&state.config.directory).map_err(ApiError::internal)?;
1917 }
1918 Ok(output)
1919}
1920
1921fn fetch_active(state: &AppState, id: &str) -> Result<SessionRecord, ApiError> {
1922 let journal = open_by_id(state, id)?;
1923 latest_lifecycle(&journal).ok_or_else(ApiError::not_found)
1924}
1925
1926fn session_mutation(state: &AppState, id: &str) -> Result<Arc<Mutex<()>>, ApiError> {
1927 let mut sessions = state.session_mutations.lock().map_err(ApiError::internal)?;
1928 sessions.retain(|_, lock| lock.strong_count() > 0);
1929 if let Some(lock) = sessions.get(id).and_then(Weak::upgrade) {
1930 return Ok(lock);
1931 }
1932 let lock = Arc::new(Mutex::new(()));
1933 sessions.insert(id.to_owned(), Arc::downgrade(&lock));
1934 Ok(lock)
1935}
1936
1937fn open_by_id(state: &AppState, id: &str) -> Result<SessionJournal, ApiError> {
1938 validate_session_id(id)?;
1939 let path = state.config.directory.join(format!("{id}.session-log"));
1940 SessionJournal::open(&path).map_err(|error| {
1941 if !path.exists() {
1942 ApiError::not_found()
1943 } else {
1944 ApiError::internal(error)
1945 }
1946 })
1947}
1948
1949fn latest_lifecycle(journal: &SessionJournal) -> Option<SessionRecord> {
1950 journal
1951 .records()
1952 .iter()
1953 .rev()
1954 .find(|record| record.kind == LIFECYCLE_SIDEBAND)
1955 .and_then(|record| serde_json::from_value(record.value.clone()).ok())
1956}
1957
1958fn append_lifecycle(journal: &mut SessionJournal, record: &SessionRecord) -> Result<(), ApiError> {
1959 journal
1960 .append_control(
1961 LIFECYCLE_SIDEBAND,
1962 now(),
1963 serde_json::to_value(record).map_err(ApiError::internal)?,
1964 )
1965 .map_err(ApiError::internal)
1966}
1967
1968fn commands(journal: &SessionJournal) -> BTreeMap<String, SessionCommand> {
1969 let mut commands = BTreeMap::new();
1970 for record in journal
1971 .records()
1972 .iter()
1973 .filter(|record| record.kind == COMMAND_SIDEBAND)
1974 {
1975 if let Ok(command) = serde_json::from_value::<SessionCommand>(record.value.clone()) {
1976 commands.insert(command.id.clone(), command);
1977 }
1978 }
1979 commands
1980}
1981
1982fn append_command(journal: &mut SessionJournal, command: &SessionCommand) -> Result<(), ApiError> {
1983 journal
1984 .append_control(
1985 COMMAND_SIDEBAND,
1986 now(),
1987 serde_json::to_value(command).map_err(ApiError::internal)?,
1988 )
1989 .map_err(ApiError::internal)
1990}
1991
1992fn stop_requests(journal: &SessionJournal) -> BTreeMap<String, SessionStopRequest> {
1993 let mut requests = BTreeMap::new();
1994 for record in journal
1995 .records()
1996 .iter()
1997 .filter(|record| record.kind == STOP_SIDEBAND)
1998 {
1999 if let Ok(request) = serde_json::from_value::<SessionStopRequest>(record.value.clone()) {
2000 requests.insert(request.id.clone(), request);
2001 }
2002 }
2003 requests
2004}
2005
2006fn append_stop_request(
2007 journal: &mut SessionJournal,
2008 request: &SessionStopRequest,
2009) -> Result<(), ApiError> {
2010 journal
2011 .append_control(
2012 STOP_SIDEBAND,
2013 now(),
2014 serde_json::to_value(request).map_err(ApiError::internal)?,
2015 )
2016 .map_err(ApiError::internal)
2017}
2018
2019fn materialize(
2020 mut record: SessionRecord,
2021 journal: &SessionJournal,
2022 provider_cost_compatibility: Option<ProviderCostCompatibility>,
2023) -> SessionRecord {
2024 let log = journal.list();
2025 record.state["sessionId"] = json!(log.header.session_id);
2026 if !record.state.get("transcript").is_some_and(Value::is_array) {
2027 record.state["transcript"] = Value::Array(
2028 log.events
2029 .iter()
2030 .enumerate()
2031 .filter_map(|(position, event)| transcript_entry(position, event))
2032 .collect(),
2033 );
2034 }
2035 if !record.state.get("events").is_some_and(Value::is_array) {
2036 record.state["events"] = serde_json::to_value(&log.events).unwrap_or(Value::Null);
2037 }
2038 let context_state = record
2039 .state
2040 .get("historyIngress")
2041 .filter(|state| state.get("chatendMetadata").is_some())
2042 .unwrap_or(&record.state);
2043 let default_provider_model = provider_cost_compatibility
2044 .and_then(|compatibility| (compatibility.session_model)(context_state))
2045 .or_else(|| {
2046 provider_cost_compatibility
2047 .and_then(|compatibility| (compatibility.session_model)(&record.state))
2048 });
2049 let exact_chatend = context_state
2050 .get("chatendMetadata")
2051 .cloned()
2052 .and_then(|value| serde_json::from_value::<chatend::SessionMetadata>(value).ok())
2053 .and_then(|metadata| match provider_cost_compatibility {
2054 Some(compatibility) => chatend::Chatend::replay_with_provider_costs(
2055 metadata,
2056 &log,
2057 default_provider_model.as_deref(),
2058 Some(compatibility.estimator),
2059 )
2060 .ok(),
2061 None => chatend::Chatend::replay(metadata, &log).ok(),
2062 });
2063 if let Some(chatend) = exact_chatend {
2064 let boxes = serde_json::to_value(&chatend.boxes).unwrap_or(Value::Null);
2065 let projection = chatend.projection();
2066 let chatend_text = Value::String(projection.render());
2067 let context = serde_json::to_value(projection).unwrap_or(Value::Null);
2068 record.state["boxes"] = boxes.clone();
2069 record.state["context"] = context.clone();
2070 record.state["chatendText"] = chatend_text.clone();
2071 if let Some(ingress) = record
2072 .state
2073 .get_mut("historyIngress")
2074 .and_then(Value::as_object_mut)
2075 {
2076 ingress.insert("boxes".into(), boxes);
2077 ingress.insert("context".into(), context);
2078 ingress.insert("chatendText".into(), chatend_text);
2079 }
2080 }
2081 record
2082}
2083
2084fn summary_state(control: &Value, journal: &SessionJournal) -> Value {
2085 let log = journal.list();
2086 let first_user = log
2087 .events
2088 .iter()
2089 .find(|event| event.role == Role::UserMessage)
2090 .map(display_text)
2091 .map(|text| text.chars().take(512).collect::<String>());
2092 json!({
2093 "sessionType":control.get("sessionType"),
2094 "channel":control.get("channel"),
2095 "freeTime":control.get("freeTime"),
2096 "orchestration":control.get("orchestration"),
2097 "ingressSource":control.get("ingressSource"),
2098 "firstUserMessage":first_user,
2099 "boxCount":log.events.len(),
2100 "eventCount":log.events.len(),
2101 "pendingTurn":control.get("pendingTurn").cloned().unwrap_or(Value::Bool(false)),
2102 })
2103}
2104
2105fn persisted_context_kind(event: &kcode_session_log::SessionEvent) -> Option<Value> {
2106 serde_json::from_str::<Value>(&event.text)
2107 .ok()?
2108 .get("kind")
2109 .cloned()
2110}
2111
2112fn display_text(event: &kcode_session_log::SessionEvent) -> String {
2113 persisted_context_kind(event)
2114 .and_then(|kind| {
2115 (kind.get("type").and_then(Value::as_str) == Some("box_created"))
2116 .then(|| {
2117 kind.get("content")
2118 .and_then(|content| content.get("text"))
2119 .and_then(Value::as_str)
2120 .map(str::to_owned)
2121 })
2122 .flatten()
2123 })
2124 .unwrap_or_else(|| event.text.clone())
2125}
2126
2127fn transcript_entry(position: usize, event: &kcode_session_log::SessionEvent) -> Option<Value> {
2128 let kind = persisted_context_kind(event);
2129 let box_content = kind
2130 .as_ref()
2131 .filter(|kind| kind.get("type").and_then(Value::as_str) == Some("box_created"))
2132 .and_then(|kind| kind.get("content"));
2133 let metadata = box_content
2134 .and_then(|content| content.get("metadata"))
2135 .filter(|value| value.is_object());
2136 let role = match event.role {
2137 Role::UserMessage => "user",
2138 Role::KennedyMessage => "kennedy",
2139 Role::SystemError => "system",
2140 Role::SystemMessage => (box_content?
2141 .get("metadata")
2142 .and_then(|metadata| metadata.get("transcriptRole"))
2143 .and_then(Value::as_str)
2144 == Some("system"))
2145 .then_some("system")?,
2146 _ => return None,
2147 };
2148 let mut item = json!({
2149 "role":role,
2150 "content":display_text(event),
2151 "boxId":position + 1,
2152 });
2153 if let Some(objects) = box_content
2154 .and_then(|content| content.get("objects"))
2155 .filter(|value| value.is_array())
2156 {
2157 item["objects"] = objects.clone();
2158 }
2159 if let Some(metadata) = metadata {
2160 for key in ["inputKind", "externalEventId"] {
2161 if let Some(value) = metadata.get(key) {
2162 item[key] = value.clone();
2163 }
2164 }
2165 if let Some(attachments) = metadata.get("attachments").filter(|value| value.is_array()) {
2166 item["attachments"] = attachments.clone();
2167 } else if let Some(media) = metadata.get("media").filter(|value| value.is_object()) {
2168 item["attachments"] = json!([media]);
2169 }
2170 }
2171 Some(item)
2172}
2173
2174fn control_state(value: &Value) -> Value {
2175 const KEYS: &[&str] = &[
2176 "format",
2177 "version",
2178 "stateVersion",
2179 "sessionId",
2180 "sessionType",
2181 "sourceSessionType",
2182 "channel",
2183 "freeTime",
2184 "selfTimeIntent",
2185 "orchestration",
2186 "provenanceId",
2187 "rustLibSessionId",
2188 "rootNodeIds",
2189 "referenceRootNodeIds",
2190 "startedAt",
2191 "pendingTurn",
2192 "pendingExternalEventId",
2193 "roundsUsed",
2194 "completed",
2195 "sessionObjectId",
2196 "commitReceipt",
2197 "commitAuthor",
2198 "providerModel",
2199 "kwebPlan",
2200 "startIdempotencyId",
2201 "ingressSource",
2202 "chatendMetadata",
2203 "sessionStatus",
2204 "historyIngress",
2205 ];
2206 let mut output = Map::new();
2207 for key in KEYS {
2208 if let Some(item) = value.get(*key) {
2209 if *key == "commitReceipt" && item.is_null() {
2210 continue;
2211 }
2212 let item = if *key == "historyIngress" {
2213 control_state(item)
2214 } else {
2215 item.clone()
2216 };
2217 output.insert((*key).into(), item);
2218 }
2219 }
2220 Value::Object(output)
2221}
2222
2223fn compact_control_journals(directory: &FilePath) -> anyhow::Result<()> {
2224 let mut paths = std::fs::read_dir(directory)?
2225 .filter_map(Result::ok)
2226 .map(|entry| entry.path())
2227 .filter(|path| path.extension().and_then(|value| value.to_str()) == Some(CONTROL_EXTENSION))
2228 .collect::<Vec<_>>();
2229 paths.sort();
2230 for path in paths {
2231 compact_control_journal(&path)?;
2232 }
2233 Ok(())
2234}
2235
2236fn compact_control_journal(path: &FilePath) -> anyhow::Result<()> {
2237 let original_bytes = std::fs::metadata(path)
2238 .with_context(|| format!("reading metadata for {}", path.display()))?
2239 .len();
2240 if original_bytes >= 16 * 1024 * 1024 {
2241 tracing::info!(
2242 path = %path.display(),
2243 original_bytes,
2244 "Compacting legacy Session History control journal"
2245 );
2246 }
2247
2248 let mut journal = ControlJournal::open(path.to_path_buf())?
2249 .with_context(|| format!("session-control journal {} disappeared", path.display()))?;
2250 let repaired_bytes = std::fs::metadata(path)?.len();
2251 let tail_repaired = repaired_bytes != original_bytes;
2252 let mut latest_lifecycle = None;
2253 let mut latest_commands = BTreeMap::<String, (u64, ControlRecord)>::new();
2254 let mut latest_stop_requests = BTreeMap::<String, (u64, ControlRecord)>::new();
2255 let mut retained_other = Vec::new();
2256 let mut needs_rewrite = false;
2257
2258 for (sequence, mut record) in journal.records().iter().cloned().enumerate() {
2259 let sequence = sequence as u64;
2260 match record.kind.as_str() {
2261 LIFECYCLE_SIDEBAND => {
2262 if let Some(state) = record.value.get_mut("state") {
2263 let projected = control_state(state);
2264 if *state != projected {
2265 *state = projected;
2266 needs_rewrite = true;
2267 }
2268 }
2269 if latest_lifecycle.replace((sequence, record)).is_some() {
2270 needs_rewrite = true;
2271 }
2272 }
2273 COMMAND_SIDEBAND => {
2274 let id = record
2275 .value
2276 .get("id")
2277 .and_then(Value::as_str)
2278 .context("session command record has no ID")?
2279 .to_owned();
2280 if latest_commands.insert(id, (sequence, record)).is_some() {
2281 needs_rewrite = true;
2282 }
2283 }
2284 STOP_SIDEBAND => {
2285 let id = record
2286 .value
2287 .get("id")
2288 .and_then(Value::as_str)
2289 .context("session stop record has no ID")?
2290 .to_owned();
2291 if latest_stop_requests
2292 .insert(id, (sequence, record))
2293 .is_some()
2294 {
2295 needs_rewrite = true;
2296 }
2297 }
2298 _ => retained_other.push((sequence, record)),
2299 }
2300 }
2301
2302 if needs_rewrite {
2303 let mut retained = retained_other;
2304 retained.extend(latest_lifecycle);
2305 retained.extend(latest_commands.into_values());
2306 retained.extend(latest_stop_requests.into_values());
2307 retained.sort_by_key(|(sequence, _)| *sequence);
2308 journal.replace(retained.into_iter().map(|(_, record)| record))?;
2309 }
2310
2311 if needs_rewrite || tail_repaired {
2312 tracing::info!(
2313 path = %path.display(),
2314 original_bytes,
2315 compacted_bytes = std::fs::metadata(path)?.len(),
2316 "Compacted Session History control journal"
2317 );
2318 }
2319 Ok(())
2320}
2321
2322fn journal_paths(directory: &FilePath) -> Result<Vec<PathBuf>, ApiError> {
2323 let mut paths = std::fs::read_dir(directory)
2324 .map_err(ApiError::internal)?
2325 .filter_map(Result::ok)
2326 .map(|entry| entry.path())
2327 .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("session-log"))
2328 .collect::<Vec<_>>();
2329 paths.sort();
2330 Ok(paths)
2331}
2332
2333fn open_listed_journals(paths: Vec<PathBuf>) -> Result<Vec<SessionJournal>, ApiError> {
2334 let mut journals = Vec::with_capacity(paths.len());
2335 for path in paths {
2336 match SessionJournal::open_existing(&path) {
2337 Ok(Some(journal)) => journals.push(journal),
2338 Ok(None) => {}
2339 Err(_) if !path.exists() => {}
2340 Err(error) => return Err(ApiError::internal(error)),
2341 }
2342 }
2343 Ok(journals)
2344}
2345
2346fn read_completed_ids(path: &FilePath) -> anyhow::Result<Vec<String>> {
2347 Ok(read_completion_receipts(path)?
2348 .into_iter()
2349 .map(|receipt| receipt.session_object_id)
2350 .collect())
2351}
2352
2353fn read_completion_receipts(path: &FilePath) -> anyhow::Result<Vec<CompletionReceipt>> {
2354 let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
2355 let mut receipts = Vec::new();
2356 let mut seen = HashSet::new();
2357 for line in BufReader::new(file).lines() {
2358 let line = line?;
2359 let line = line.trim();
2360 if line.is_empty() {
2361 continue;
2362 }
2363 let receipt = if line.starts_with('{') {
2364 serde_json::from_str::<CompletionReceipt>(line)
2365 .context("decoding Session History completion receipt")?
2366 } else {
2367 CompletionReceipt {
2368 transaction_id: None,
2369 session_object_id: line.to_owned(),
2370 session_id: None,
2371 session_type: None,
2372 created_at: None,
2373 committed_at: None,
2374 ingress_source: None,
2375 node_ids: BTreeMap::new(),
2376 object_ids: BTreeMap::new(),
2377 }
2378 };
2379 if seen.insert(receipt.session_object_id.clone()) {
2380 receipts.push(receipt);
2381 }
2382 }
2383 Ok(receipts)
2384}
2385
2386#[cfg(test)]
2387fn append_completed_id(path: &FilePath, id: &str) -> anyhow::Result<()> {
2388 append_completion_receipt(
2389 path,
2390 &CompletionReceipt {
2391 transaction_id: None,
2392 session_object_id: id.into(),
2393 session_id: None,
2394 session_type: None,
2395 created_at: None,
2396 committed_at: None,
2397 ingress_source: None,
2398 node_ids: BTreeMap::new(),
2399 object_ids: BTreeMap::new(),
2400 },
2401 )
2402}
2403
2404fn append_completion_receipt(path: &FilePath, receipt: &CompletionReceipt) -> anyhow::Result<()> {
2405 if read_completed_ids(path)?
2406 .iter()
2407 .any(|existing| existing == &receipt.session_object_id)
2408 {
2409 return Ok(());
2410 }
2411 let mut file = OpenOptions::new().append(true).open(path)?;
2412 writeln!(file, "{}", serde_json::to_string(receipt)?)?;
2413 file.flush()?;
2414 file.sync_data()?;
2415 Ok(())
2416}
2417
2418fn create_private_directory(path: &FilePath) -> anyhow::Result<()> {
2419 if path.is_dir() {
2420 return Ok(());
2421 }
2422 let mut builder = std::fs::DirBuilder::new();
2423 builder.recursive(true);
2424 #[cfg(unix)]
2425 {
2426 use std::os::unix::fs::DirBuilderExt as _;
2427 builder.mode(0o700);
2428 }
2429 builder
2430 .create(path)
2431 .with_context(|| format!("creating {}", path.display()))?;
2432 let parent = path
2433 .parent()
2434 .filter(|parent| !parent.as_os_str().is_empty())
2435 .unwrap_or_else(|| FilePath::new("."));
2436 sync_directory(parent)
2437}
2438
2439fn sync_directory(path: &FilePath) -> anyhow::Result<()> {
2440 File::open(path)
2441 .with_context(|| format!("opening directory {} for sync", path.display()))?
2442 .sync_all()
2443 .with_context(|| format!("syncing directory {}", path.display()))
2444}
2445
2446fn validate_started_at(value: &str) -> Result<(), ApiError> {
2447 DateTime::parse_from_rfc3339(value)
2448 .map(|_| ())
2449 .map_err(|_| ApiError::bad("started_at must be an RFC 3339 timestamp"))
2450}
2451
2452fn validate_idempotency(value: &str) -> Result<(), ApiError> {
2453 if value.is_empty() || value.len() > 255 {
2454 return Err(ApiError::bad(
2455 "idempotency_id must contain between 1 and 255 bytes",
2456 ));
2457 }
2458 Ok(())
2459}
2460
2461fn validate_session_id(value: &str) -> Result<(), ApiError> {
2462 Uuid::parse_str(value)
2463 .map(|_| ())
2464 .map_err(|_| ApiError::bad("invalid session ID"))
2465}
2466
2467fn require_version(record: &SessionRecord, expected: i64) -> Result<(), ApiError> {
2468 if record.version != expected {
2469 return Err(ApiError::conflict(format!(
2470 "Expected session version {expected}, found {}.",
2471 record.version
2472 )));
2473 }
2474 Ok(())
2475}
2476
2477fn now() -> String {
2478 Utc::now().to_rfc3339()
2479}
2480
2481#[cfg(test)]
2482mod tests {
2483 use std::time::{SystemTime, UNIX_EPOCH};
2484
2485 use super::*;
2486
2487 fn root(label: &str) -> PathBuf {
2488 std::env::temp_dir().join(format!(
2489 "kennedy-session-history-{label}-{}-{}",
2490 std::process::id(),
2491 SystemTime::now()
2492 .duration_since(UNIX_EPOCH)
2493 .unwrap()
2494 .as_nanos()
2495 ))
2496 }
2497
2498 fn service(label: &str) -> SessionHistory {
2499 let root = root(label);
2500 std::fs::create_dir_all(&root).unwrap();
2501 SessionHistory::open(Config {
2502 directory: root.join("sessions"),
2503 completed_list: root.join("session-history.txt"),
2504 provider_cost_compatibility: None,
2505 })
2506 .unwrap()
2507 }
2508
2509 fn no_legacy_cost(
2510 _: &str,
2511 _: &chatend::ProviderMetering,
2512 ) -> Option<chatend::ProviderCostEstimate> {
2513 None
2514 }
2515
2516 #[test]
2517 fn metadata_free_session_log_archives_are_not_misclassified_as_chatend_archives() {
2518 let root = root("metadata-free-session-log-archive");
2519 std::fs::create_dir_all(&root).unwrap();
2520 let service = SessionHistory::open(Config {
2521 directory: root.join("sessions"),
2522 completed_list: root.join("session-history.txt"),
2523 provider_cost_compatibility: Some(ProviderCostCompatibility {
2524 session_model: |_| Some("gpt-test".into()),
2525 estimator: no_legacy_cost,
2526 }),
2527 })
2528 .unwrap();
2529 let archive = json!({
2530 "header": {
2531 "formatVersion": kcode_session_log::FORMAT_VERSION,
2532 "sessionId": "e9469f2a-003a-4717-9e38-0f2d79fe9218",
2533 "createdAt": "2026-07-24T13:04:28.017Z"
2534 },
2535 "events": [{
2536 "role": "user-message",
2537 "text": "This valid pre-Session-History archive has no Chatend metadata."
2538 }]
2539 });
2540
2541 assert_eq!(
2542 service
2543 .legacy_provider_cost_summary_for_archive(&archive, None)
2544 .unwrap(),
2545 None
2546 );
2547 std::fs::remove_dir_all(root).unwrap();
2548 }
2549
2550 async fn start(service: &SessionHistory, idempotency_id: &str) -> SessionRecord {
2551 service
2552 .start(StartSession {
2553 idempotency_id: idempotency_id.into(),
2554 started_at: "2026-07-23T00:00:00Z".into(),
2555 session_type: "conversation".into(),
2556 duration_minutes: None,
2557 custom_prompt: None,
2558 })
2559 .await
2560 .unwrap()
2561 .value
2562 }
2563
2564 #[test]
2565 fn control_state_retains_only_authoritative_lifecycle_and_recovery_fields() {
2566 let saved = control_state(&json!({
2567 "sessionType":"conversation",
2568 "pendingTurn":true,
2569 "transcript":[{"role":"user","content":"hello"}],
2570 "boxCount":1,
2571 "eventCount":1,
2572 "boxes":{"1":{"id":1}},
2573 "events":[{"id":1}],
2574 "context":{"estimatedTokens":10},
2575 "chatendMetadata":{
2576 "sessionId":"session-1",
2577 "kind":"conversation",
2578 "createdAt":"2026-07-23T00:00:00Z",
2579 "effectiveContextTokens":100
2580 },
2581 "sessionStatus":{"currentContextTokens":10,"contextLimitTokens":100},
2582 "chatendText":"hello",
2583 "historyIngress":{
2584 "format":"kennedy-chatend",
2585 "sessionType":"history-ingress",
2586 "chatendMetadata":{
2587 "sessionId":"session-1",
2588 "kind":"history_ingress",
2589 "createdAt":"2026-07-23T00:00:00Z",
2590 "effectiveContextTokens":100
2591 },
2592 "completed":true,
2593 "commitReceipt":{"sessionObjectId":"A1234567"},
2594 "boxes":{"2":{"id":2}},
2595 "events":[{"id":2}],
2596 "context":{"estimatedTokens":20},
2597 "sessionStatus":{"currentContextTokens":20,"contextLimitTokens":100},
2598 "chatendText":"ingress"
2599 },
2600 "unrecognized":"discard me"
2601 }));
2602 assert_eq!(saved["sessionType"], "conversation");
2603 assert_eq!(saved["pendingTurn"], true);
2604 assert_eq!(saved["chatendMetadata"]["kind"], "conversation");
2605 assert_eq!(saved["sessionStatus"]["currentContextTokens"], 10);
2606 assert_eq!(saved["historyIngress"]["sessionType"], "history-ingress");
2607 assert_eq!(
2608 saved["historyIngress"]["chatendMetadata"]["kind"],
2609 "history_ingress"
2610 );
2611 assert_eq!(saved["historyIngress"]["completed"], true);
2612 assert_eq!(
2613 saved["historyIngress"]["sessionStatus"]["currentContextTokens"],
2614 20
2615 );
2616 assert_eq!(
2617 saved["historyIngress"]["commitReceipt"]["sessionObjectId"],
2618 "A1234567"
2619 );
2620 for field in [
2621 "transcript",
2622 "boxCount",
2623 "eventCount",
2624 "boxes",
2625 "events",
2626 "context",
2627 "chatendText",
2628 ] {
2629 assert!(saved.get(field).is_none(), "{field} was retained");
2630 assert!(
2631 saved["historyIngress"].get(field).is_none(),
2632 "nested {field} was retained"
2633 );
2634 }
2635 assert!(saved.get("unrecognized").is_none());
2636 assert!(
2637 control_state(&json!({"commitReceipt":null}))
2638 .get("commitReceipt")
2639 .is_none()
2640 );
2641 }
2642
2643 #[test]
2644 fn startup_compacts_superseded_control_records_without_losing_recovery_state() {
2645 let root = root("control-compaction");
2646 let sessions = root.join("sessions");
2647 std::fs::create_dir_all(&sessions).unwrap();
2648 let id = "9078bb6e-0931-4477-9ce9-b1430d0335a2";
2649 drop(
2650 SessionStore::new(&sessions)
2651 .create_session(id, "2026-07-24T00:00:00Z")
2652 .unwrap(),
2653 );
2654 let control_path = control_path(&sessions, id);
2655 let mut control = ControlJournal::create(control_path.clone()).unwrap();
2656 let base = SessionRecord {
2657 id: id.into(),
2658 phase: "active".into(),
2659 started_at: "2026-07-24T00:00:00Z".into(),
2660 updated_at: "2026-07-24T00:00:00Z".into(),
2661 state: json!({
2662 "sessionType":"conversation",
2663 "boxes":{"1":{"canonical":{"content":{"text":"x".repeat(20_000)}}}},
2664 "events":[{"kind":"old presentation"}],
2665 "context":{"estimatedTokens":5_000},
2666 "chatendText":"x".repeat(20_000),
2667 }),
2668 provenance_id: None,
2669 version: 1,
2670 last_user_message_at: None,
2671 ended_at: None,
2672 ingress_failure_count: 0,
2673 ingress_failures: json!([]),
2674 ingress_next_attempt_at: None,
2675 summary: false,
2676 };
2677 control
2678 .append(
2679 LIFECYCLE_SIDEBAND,
2680 "2026-07-24T00:00:00Z",
2681 serde_json::to_value(&base).unwrap(),
2682 )
2683 .unwrap();
2684 let pending_command = SessionCommand {
2685 id: "command-1".into(),
2686 conversation_id: id.into(),
2687 sequence: 1,
2688 kind: "message".into(),
2689 payload: json!({"text":"hello"}),
2690 status: "pending".into(),
2691 cancel_requested: false,
2692 outcome: None,
2693 created_at: "2026-07-24T00:00:01Z".into(),
2694 processing_started_at: None,
2695 completed_at: None,
2696 idempotency_id: "message-1".into(),
2697 };
2698 control
2699 .append(
2700 COMMAND_SIDEBAND,
2701 "2026-07-24T00:00:01Z",
2702 serde_json::to_value(&pending_command).unwrap(),
2703 )
2704 .unwrap();
2705 let mut latest = base;
2706 latest.version = 2;
2707 latest.updated_at = "2026-07-24T00:00:02Z".into();
2708 latest.state["pendingTurn"] = json!(true);
2709 latest.state["historyIngress"] = json!({
2710 "format":"kennedy-chatend",
2711 "sessionType":"history-ingress",
2712 "completed":true,
2713 "commitReceipt":{"sessionObjectId":"A1234567"},
2714 "boxes":{"2":{"canonical":{"content":{"text":"y".repeat(20_000)}}}},
2715 "events":[{"kind":"ingress presentation"}],
2716 "context":{"estimatedTokens":7_000},
2717 "chatendText":"y".repeat(20_000),
2718 });
2719 control
2720 .append(
2721 LIFECYCLE_SIDEBAND,
2722 "2026-07-24T00:00:02Z",
2723 serde_json::to_value(&latest).unwrap(),
2724 )
2725 .unwrap();
2726 let mut completed_command = pending_command;
2727 completed_command.status = "complete".into();
2728 completed_command.outcome = Some(json!({"accepted":true}));
2729 completed_command.completed_at = Some("2026-07-24T00:00:03Z".into());
2730 control
2731 .append(
2732 COMMAND_SIDEBAND,
2733 "2026-07-24T00:00:03Z",
2734 serde_json::to_value(&completed_command).unwrap(),
2735 )
2736 .unwrap();
2737 drop(control);
2738 let original_bytes = std::fs::metadata(&control_path).unwrap().len();
2739
2740 let service = SessionHistory::open(Config {
2741 directory: sessions.clone(),
2742 completed_list: root.join("session-history.txt"),
2743 provider_cost_compatibility: None,
2744 })
2745 .unwrap();
2746 let compacted_bytes = std::fs::metadata(&control_path).unwrap().len();
2747 assert!(compacted_bytes < original_bytes / 10);
2748 let journal = open_by_id(&service.state, id).unwrap();
2749 assert_eq!(journal.records().len(), 2);
2750 let restored = latest_lifecycle(&journal).unwrap();
2751 assert_eq!(restored.version, 2);
2752 assert_eq!(restored.state["pendingTurn"], true);
2753 assert!(restored.state.get("boxes").is_none());
2754 assert!(restored.state.get("events").is_none());
2755 assert!(restored.state.get("context").is_none());
2756 assert!(restored.state.get("chatendText").is_none());
2757 assert_eq!(
2758 restored.state["historyIngress"]["commitReceipt"]["sessionObjectId"],
2759 "A1234567"
2760 );
2761 assert!(restored.state["historyIngress"].get("boxes").is_none());
2762 let restored_commands = commands(&journal);
2763 assert_eq!(restored_commands.len(), 1);
2764 assert_eq!(restored_commands["command-1"].status, "complete");
2765 assert_eq!(
2766 restored_commands["command-1"].outcome,
2767 Some(json!({"accepted":true}))
2768 );
2769 }
2770
2771 #[tokio::test]
2772 async fn managed_session_log_and_control_journal_remain_coordinated() {
2773 let service = service("commands");
2774 let record = start(&service, "start-1").await;
2775 let id = record.id.clone();
2776 let mut journal = open_by_id(&service.state, &id).unwrap();
2777 journal
2778 .log
2779 .add_event(Role::SystemError, "The message exceeded capacity.")
2780 .unwrap();
2781 drop(journal);
2782 let command = service
2783 .enqueue(
2784 &id,
2785 NewCommand {
2786 idempotency_id: "message-1".into(),
2787 kind: "message".into(),
2788 payload: json!({"text":"hello"}),
2789 },
2790 )
2791 .await
2792 .unwrap()
2793 .value;
2794 assert_eq!(command.status, "pending");
2795 let materialized = service.get(&id).await.unwrap();
2796 assert!(materialized.state.get("chatendText").is_none());
2797 assert_eq!(
2798 materialized.state["transcript"][0],
2799 json!({
2800 "role":"system",
2801 "content":"The message exceeded capacity.",
2802 "boxId":1,
2803 })
2804 );
2805 let listed = service.command_heads().await.unwrap();
2806 assert_eq!(listed.len(), 1);
2807 assert_eq!(
2808 std::fs::read_dir(&service.state.config.directory)
2809 .unwrap()
2810 .count(),
2811 2
2812 );
2813 }
2814
2815 #[tokio::test]
2816 async fn stop_requests_are_durable_without_advancing_lifecycle() {
2817 let service = service("stop-requests");
2818 let record = start(&service, "start-1").await;
2819 let command = service
2820 .enqueue(
2821 &record.id,
2822 NewCommand {
2823 idempotency_id: "message-1".into(),
2824 kind: "message".into(),
2825 payload: json!({"text":"hello"}),
2826 },
2827 )
2828 .await
2829 .unwrap()
2830 .value;
2831 service.claim_command(&command.id).await.unwrap();
2832
2833 let created = service
2834 .request_stop(
2835 &record.id,
2836 NewStopRequest {
2837 idempotency_id: "stop-1".into(),
2838 scope: "turn".into(),
2839 },
2840 )
2841 .await
2842 .unwrap();
2843 assert!(created.created);
2844 assert_eq!(created.value.scope, "turn");
2845 assert_eq!(created.value.status, "pending");
2846
2847 let unchanged = service.get(&record.id).await.unwrap();
2848 assert_eq!(unchanged.phase, "active");
2849 assert_eq!(unchanged.version, record.version);
2850 assert!(
2851 service.command_heads().await.unwrap()[0].cancel_requested,
2852 "turn stop must also cancel the active browser command"
2853 );
2854 assert_eq!(
2855 service.stop_heads().await.unwrap(),
2856 vec![created.value.clone()]
2857 );
2858
2859 let completed = service
2860 .complete_stop(
2861 &created.value.id,
2862 StopOutcome {
2863 outcome: json!({"status":"stopped"}),
2864 },
2865 )
2866 .await
2867 .unwrap();
2868 assert_eq!(completed.status, "complete");
2869 assert_eq!(completed.outcome, Some(json!({"status":"stopped"})));
2870 assert!(service.stop_heads().await.unwrap().is_empty());
2871 }
2872
2873 #[tokio::test]
2874 async fn current_work_stop_derives_scope_and_notifies_live_or_late_listeners() {
2875 let service = service("current-work-stop");
2876 let conversation = start(&service, "conversation-start").await;
2877 let live = service.listen_for_stop(&conversation.id).unwrap();
2878 let requested = service
2879 .request_current_work_stop(
2880 &conversation.id,
2881 NewCurrentWorkStop {
2882 idempotency_id: "conversation-stop".into(),
2883 },
2884 )
2885 .await
2886 .unwrap()
2887 .value;
2888 assert_eq!(requested.scope, "turn");
2889 tokio::time::timeout(std::time::Duration::from_secs(1), live.requested())
2890 .await
2891 .expect("live stop listener was not notified");
2892
2893 let self_time = service
2894 .start(StartSession {
2895 idempotency_id: "self-time-start".into(),
2896 started_at: "2026-07-23T01:00:00Z".into(),
2897 session_type: "free-time".into(),
2898 duration_minutes: Some(5.0),
2899 custom_prompt: None,
2900 })
2901 .await
2902 .unwrap()
2903 .value;
2904 let requested = service
2905 .request_current_work_stop(
2906 &self_time.id,
2907 NewCurrentWorkStop {
2908 idempotency_id: "self-time-stop".into(),
2909 },
2910 )
2911 .await
2912 .unwrap()
2913 .value;
2914 assert_eq!(requested.scope, "self-time-run");
2915 let late = service.listen_for_stop(&self_time.id).unwrap();
2916 tokio::time::timeout(std::time::Duration::from_secs(1), late.requested())
2917 .await
2918 .expect("durable stop did not signal a late listener");
2919 }
2920
2921 #[test]
2922 fn current_work_stop_scope_uses_typed_kind_and_durable_phase() {
2923 let record = |phase: &str, kind: chatend::SessionKind| SessionRecord {
2924 id: Uuid::new_v4().to_string(),
2925 phase: phase.into(),
2926 started_at: "2026-08-01T00:00:00Z".into(),
2927 updated_at: "2026-08-01T00:00:00Z".into(),
2928 state: json!({
2929 "sessionType":"wakeup",
2930 "chatendMetadata":chatend::SessionMetadata {
2931 session_id: Uuid::new_v4().to_string(),
2932 kind,
2933 created_at: "2026-08-01T00:00:00Z".into(),
2934 effective_context_tokens: 1,
2935 channel: Value::Null,
2936 },
2937 }),
2938 provenance_id: None,
2939 version: 1,
2940 last_user_message_at: None,
2941 ended_at: None,
2942 ingress_failure_count: 0,
2943 ingress_failures: json!([]),
2944 ingress_next_attempt_at: None,
2945 summary: false,
2946 };
2947
2948 let mut restored_conversation = record("active", chatend::SessionKind::Telegram);
2949 restored_conversation.state["historyIngress"] = json!({
2950 "chatendMetadata":chatend::SessionMetadata {
2951 session_id: Uuid::new_v4().to_string(),
2952 kind: chatend::SessionKind::HistoryIngress,
2953 created_at: "2026-08-01T00:00:00Z".into(),
2954 effective_context_tokens: 1,
2955 channel: Value::Null,
2956 },
2957 });
2958 assert_eq!(
2959 current_work_stop_scope(&restored_conversation).unwrap(),
2960 "turn"
2961 );
2962 assert_eq!(
2963 current_work_stop_scope(&record("active", chatend::SessionKind::SelfTime)).unwrap(),
2964 "self-time-run"
2965 );
2966 assert_eq!(
2967 current_work_stop_scope(&record(
2968 "ingress_in_progress",
2969 chatend::SessionKind::Telegram
2970 ))
2971 .unwrap(),
2972 "session"
2973 );
2974 assert_eq!(
2975 current_work_stop_scope(&record(
2976 "active",
2977 chatend::SessionKind::Other("wakeup".into())
2978 ))
2979 .unwrap(),
2980 "session"
2981 );
2982 }
2983
2984 #[tokio::test]
2985 async fn turn_stop_does_not_hide_or_cancel_an_end_command() {
2986 let service = service("stop-during-end");
2987 let record = start(&service, "start-1").await;
2988 let command = service
2989 .enqueue(
2990 &record.id,
2991 NewCommand {
2992 idempotency_id: "end-1".into(),
2993 kind: "end".into(),
2994 payload: json!({}),
2995 },
2996 )
2997 .await
2998 .unwrap()
2999 .value;
3000 service
3001 .request_stop(
3002 &record.id,
3003 NewStopRequest {
3004 idempotency_id: "stop-1".into(),
3005 scope: "turn".into(),
3006 },
3007 )
3008 .await
3009 .unwrap();
3010 let head = &service.command_heads().await.unwrap()[0];
3011 assert_eq!(head.id, command.id);
3012 assert_eq!(head.kind, "end");
3013 assert!(!head.cancel_requested);
3014 }
3015
3016 #[tokio::test]
3017 async fn checkpointed_semantic_presentation_is_rebuilt_from_the_session_log() {
3018 let service = service("live-ui");
3019 let record = start(&service, "live-ui-start").await;
3020 let id = record.id.clone();
3021 let mut journal = open_by_id(&service.state, &id).unwrap();
3022 journal
3023 .log
3024 .add_event(Role::UserMessage, "hello from the log")
3025 .unwrap();
3026 drop(journal);
3027 let boxes = json!({
3028 "1":{
3029 "id":1,
3030 "owner":{"kind":"user"},
3031 "canonical":{"eventId":1,"content":{"text":"hello"}},
3032 "representation":{"kind":"hydrated"},
3033 "active":true
3034 }
3035 });
3036 let context = json!({"items":[{"boxId":1,"text":"hello"}]});
3037 let checkpointed = service
3038 .checkpoint(
3039 &id,
3040 Checkpoint {
3041 expected_version: record.version,
3042 state: json!({
3043 "sessionId":id,
3044 "sessionType":"conversation",
3045 "boxes":boxes,
3046 "events":[],
3047 "context":context,
3048 "chatendText":"hello",
3049 "historyIngress":{"format":"kennedy-chatend","version":1}
3050 }),
3051 user_activity: false,
3052 },
3053 )
3054 .await
3055 .unwrap();
3056 assert!(checkpointed.state.get("boxes").is_none());
3057 assert!(checkpointed.state.get("context").is_none());
3058 assert_eq!(
3059 checkpointed.state["transcript"][0]["content"],
3060 "hello from the log"
3061 );
3062 assert_eq!(checkpointed.state["events"][0]["role"], "user-message");
3063 assert!(checkpointed.state.get("chatendText").is_none());
3064 assert_eq!(
3065 checkpointed.state["historyIngress"]["format"],
3066 "kennedy-chatend"
3067 );
3068
3069 let fetched = service.get(&id).await.unwrap();
3070 assert!(fetched.state.get("boxes").is_none());
3071 assert!(fetched.state.get("context").is_none());
3072 assert!(fetched.state.get("chatendText").is_none());
3073 assert_eq!(
3074 fetched.state["transcript"][0]["content"],
3075 "hello from the log"
3076 );
3077 }
3078
3079 #[tokio::test]
3080 async fn materialized_context_is_the_authoritative_chatend_rendering() {
3081 let service = service("exact-context");
3082 let mut session = service
3083 .create_session(NewSession {
3084 kind: chatend::SessionKind::Conversation,
3085 created_at: "2026-07-23T00:00:00Z".into(),
3086 effective_context_tokens: 100_000,
3087 channel: Value::Null,
3088 })
3089 .unwrap();
3090 session
3091 .create_box(
3092 "2026-07-23T00:00:01Z",
3093 "User message",
3094 chatend::BoxOwner::User,
3095 chatend::BoxContent::text("literal \\\\n stays escaped\nactual newline"),
3096 )
3097 .unwrap();
3098 let id = session.id().to_owned();
3099 let metadata = session.state().metadata.clone();
3100 drop(session);
3101
3102 service
3103 .register(RegisterSession {
3104 id: id.clone(),
3105 started_at: "2026-07-23T00:00:00Z".into(),
3106 state: json!({
3107 "sessionId":id,
3108 "sessionType":"conversation",
3109 "chatendMetadata":metadata,
3110 "chatendText":"must not survive control projection",
3111 }),
3112 })
3113 .await
3114 .unwrap();
3115 let materialized = service.get(&id).await.unwrap();
3116 let chatend_text = materialized.state["chatendText"].as_str().unwrap();
3117 assert!(
3118 chatend_text
3119 .contains("[box 1 | User message | timestamp=2026-07-23T00:00:01Z | hydrated]")
3120 );
3121 assert!(chatend_text.contains("[current time: 20"));
3122 assert_eq!(
3123 materialized.state["context"]["contextBytes"],
3124 chatend_text.len() as u64
3125 );
3126 assert_eq!(
3127 materialized.state["boxes"]["1"]["canonical"]["content"]["text"],
3128 "literal \\\\n stays escaped\nactual newline"
3129 );
3130 }
3131
3132 #[tokio::test]
3133 async fn active_pending_objects_are_streamed_with_original_metadata() {
3134 let service = service("pending-object");
3135 let record = start(&service, "pending-object-start").await;
3136 let id = record.id;
3137 let mut journal = open_by_id(&service.state, &id).unwrap();
3138 let position = journal
3139 .log
3140 .add_pending_object(
3141 "object event",
3142 "photo.png",
3143 "image/png",
3144 b"\x89PNG\r\n\x1a\npayload",
3145 )
3146 .unwrap();
3147 let object = service
3148 .object(&id, &format!("pending:{}", position.index() + 1))
3149 .unwrap();
3150 assert_eq!(object.media_type, "image/png");
3151 assert_eq!(object.file_name, "photo.png");
3152 assert_eq!(object.bytes, b"\x89PNG\r\n\x1a\npayload");
3153 }
3154
3155 #[tokio::test]
3156 async fn conversation_ingress_failures_back_off_then_stop_and_can_be_retried() {
3157 let service = service("ingress-retries");
3158 let created = start(&service, "ingress-retries-start").await;
3159 let id = created.id.clone();
3160 let mut record = service
3161 .request_ingress(
3162 &id,
3163 Checkpoint {
3164 expected_version: created.version,
3165 state: created.state,
3166 user_activity: false,
3167 },
3168 )
3169 .await
3170 .unwrap();
3171
3172 for attempt in 1..=INGRESS_FAILURE_LIMIT {
3173 record = service
3174 .start_ingress(
3175 &id,
3176 StartIngress {
3177 expected_version: record.version,
3178 provenance_id: format!("session:{id}"),
3179 },
3180 )
3181 .await
3182 .unwrap();
3183 record = service
3184 .fail_ingress(
3185 &id,
3186 IngressFailure {
3187 expected_version: record.version,
3188 stage: "model_loop".into(),
3189 code: Some("ingress_error".into()),
3190 message: "transient failure".into(),
3191 rounds_used: None,
3192 context_tokens: None,
3193 context_window_tokens: None,
3194 },
3195 )
3196 .await
3197 .unwrap();
3198 assert_eq!(record.ingress_failure_count, attempt);
3199 if attempt < INGRESS_FAILURE_LIMIT {
3200 assert_eq!(record.phase, "ingress_pending");
3201 assert!(record.ingress_next_attempt_at.is_some());
3202 } else {
3203 assert_eq!(record.phase, "ingress_failed");
3204 assert!(record.ingress_next_attempt_at.is_none());
3205 }
3206 }
3207 assert_eq!(
3208 record.ingress_failures.as_array().unwrap().len(),
3209 RETAINED_INGRESS_FAILURES
3210 );
3211
3212 let retried = service
3213 .retry_ingress(
3214 &id,
3215 RetryIngress {
3216 expected_version: record.version,
3217 state: record.state,
3218 },
3219 )
3220 .await
3221 .unwrap();
3222 assert_eq!(retried.phase, "ingress_pending");
3223 assert_eq!(retried.ingress_failure_count, 0);
3224 assert!(retried.ingress_next_attempt_at.is_none());
3225 }
3226
3227 #[tokio::test]
3228 async fn prepared_ingress_is_idempotent_through_completion() {
3229 let service = service("prepared-ingress");
3230 let input = NewIngressSession {
3231 idempotency_id: "audio:recording:0".into(),
3232 started_at: "2026-01-02T03:04:05Z".into(),
3233 source_session_type: "audio".into(),
3234 kind: chatend::SessionKind::AudioIngress,
3235 effective_context_tokens: 100_000,
3236 text: "Prepared transcript".into(),
3237 metadata: json!({
3238 "kind":"audio-transcript",
3239 "recordingId":"recording",
3240 "pieceIndex":0,
3241 "pieceCount":1,
3242 }),
3243 };
3244 let created = service.enqueue_ingress(input.clone()).await.unwrap();
3245 assert!(created.created);
3246 assert_eq!(created.value.phase, "ingress_pending");
3247 assert_eq!(
3248 created.value.state["transcript"][0]["content"],
3249 "Prepared transcript"
3250 );
3251 assert_eq!(
3252 created.value.state["ingressSource"]["metadata"]["recordingId"],
3253 "recording"
3254 );
3255
3256 let replay = service.enqueue_ingress(input.clone()).await.unwrap();
3257 assert!(!replay.created);
3258 assert_eq!(replay.value.id, created.value.id);
3259
3260 let mut state = replay.value.state;
3261 state["historyIngress"] = json!({
3262 "completed":true,
3263 "sessionObjectId":"A1234567",
3264 "commitReceipt":{
3265 "sessionObjectId":"A1234567",
3266 "nodeIds":{},
3267 "objectIds":{}
3268 }
3269 });
3270 let checkpointed = service
3271 .checkpoint(
3272 &created.value.id,
3273 Checkpoint {
3274 expected_version: replay.value.version,
3275 state,
3276 user_activity: false,
3277 },
3278 )
3279 .await
3280 .unwrap();
3281 service
3282 .complete_ingress(
3283 &created.value.id,
3284 ExpectedVersion {
3285 expected_version: checkpointed.version,
3286 },
3287 )
3288 .await
3289 .unwrap();
3290
3291 let completed_replay = service.enqueue_ingress(input).await.unwrap();
3292 assert!(!completed_replay.created);
3293 assert_eq!(completed_replay.value.phase, "complete");
3294 assert_eq!(
3295 completed_replay.value.state["ingressSource"]["metadata"]["recordingId"],
3296 "recording"
3297 );
3298 }
3299
3300 #[tokio::test]
3301 async fn startup_releases_legacy_hot_loop_without_discarding_checkpoint_state() {
3302 let service = service("ingress-release");
3303 let created = start(&service, "ingress-release-start").await;
3304 let id = created.id.clone();
3305 let pending = service
3306 .request_ingress(
3307 &id,
3308 Checkpoint {
3309 expected_version: created.version,
3310 state: json!({
3311 "sessionType":"conversation",
3312 "historyIngress":{"kwebPlan":{"creates":[{"pendingId":"pending:9"}]}}
3313 }),
3314 user_activity: false,
3315 },
3316 )
3317 .await
3318 .unwrap();
3319 let started = service
3320 .start_ingress(
3321 &id,
3322 StartIngress {
3323 expected_version: pending.version,
3324 provenance_id: format!("session:{id}"),
3325 },
3326 )
3327 .await
3328 .unwrap();
3329 let mut journal = open_by_id(&service.state, &id).unwrap();
3330 let mut legacy = started;
3331 legacy.ingress_failure_count = 150;
3332 legacy.ingress_failures = Value::Array(
3333 (1..=150)
3334 .map(|attempt| json!({"attempt":attempt,"message":"legacy hot loop"}))
3335 .collect(),
3336 );
3337 legacy.version += 1;
3338 append_lifecycle(&mut journal, &legacy).unwrap();
3339 drop(journal);
3340
3341 let released = service.release_interrupted_ingress().await.unwrap();
3342 assert_eq!(released, vec![id.clone()]);
3343 let repaired = service.get(&id).await.unwrap();
3344 assert_eq!(repaired.phase, "ingress_pending");
3345 assert_eq!(repaired.ingress_failure_count, 0);
3346 assert_eq!(
3347 repaired.ingress_failures.as_array().unwrap().len(),
3348 RETAINED_INGRESS_FAILURES
3349 );
3350 assert_eq!(
3351 repaired.state["historyIngress"]["kwebPlan"]["creates"][0]["pendingId"],
3352 "pending:9"
3353 );
3354 }
3355
3356 #[tokio::test]
3357 async fn nested_history_ingress_receipt_completes_the_source_session() {
3358 let service = service("nested-completion");
3359 let record = start(&service, "nested-completion-start").await;
3360 let id = record.id.clone();
3361 let mut state = record.state.clone();
3362 state["historyIngress"] = json!({
3363 "sessionType":"history-ingress",
3364 "completed":true,
3365 "sessionObjectId":"A1234567",
3366 "commitReceipt":{
3367 "transactionId":"T1234567",
3368 "sessionObjectId":"A1234567",
3369 "nodeIds":{},
3370 "objectIds":{}
3371 }
3372 });
3373 let completed = service
3374 .complete(
3375 &id,
3376 Checkpoint {
3377 expected_version: record.version,
3378 state,
3379 user_activity: false,
3380 },
3381 )
3382 .await
3383 .unwrap();
3384 assert_eq!(completed.phase, "complete");
3385 assert_eq!(completed.state["sessionObjectId"], "A1234567");
3386 assert_eq!(
3387 completed.state["commitReceipt"]["transactionId"],
3388 "T1234567"
3389 );
3390 assert!(
3391 !service
3392 .state
3393 .config
3394 .directory
3395 .join(format!("{id}.session-log"))
3396 .exists()
3397 );
3398 assert!(!control_path(&service.state.config.directory, &id).exists());
3399 assert_eq!(
3400 read_completion_receipts(&service.state.config.completed_list).unwrap()[0]
3401 .session_object_id,
3402 "A1234567"
3403 );
3404 }
3405
3406 #[tokio::test]
3407 async fn listing_tolerates_a_session_completing_after_path_enumeration() {
3408 let service = service("concurrent-completion-list");
3409 let record = start(&service, "concurrent-completion-list-start").await;
3410 let id = record.id.clone();
3411 let listed_paths = journal_paths(&service.state.config.directory).unwrap();
3412 assert_eq!(listed_paths.len(), 1);
3413
3414 let mut state = record.state;
3415 state["sessionObjectId"] = json!("A1234567");
3416 service
3417 .complete(
3418 &id,
3419 Checkpoint {
3420 expected_version: record.version,
3421 state,
3422 user_activity: false,
3423 },
3424 )
3425 .await
3426 .unwrap();
3427
3428 assert!(open_listed_journals(listed_paths).unwrap().is_empty());
3429 let listed = service.list().await.unwrap();
3430 assert_eq!(listed.len(), 1);
3431 assert_eq!(listed[0].phase, "complete");
3432 assert_eq!(listed[0].state["sessionObjectId"], "A1234567");
3433 }
3434
3435 #[tokio::test]
3436 async fn committed_history_records_a_structured_receipt() {
3437 let service = service("completed");
3438 append_completed_id(&service.state.config.completed_list, "A1234567").unwrap();
3439 let listed = service.list().await.unwrap();
3440 assert_eq!(listed[0].state["sessionObjectId"], "A1234567");
3441 assert_eq!(
3442 listed[0].state["commitReceipt"]["sessionObjectId"],
3443 "A1234567"
3444 );
3445 let stored = std::fs::read_to_string(&service.state.config.completed_list).unwrap();
3446 let receipt: CompletionReceipt = serde_json::from_str(stored.trim()).unwrap();
3447 assert_eq!(receipt.session_object_id, "A1234567");
3448 }
3449}