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