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