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