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