Skip to main content

kcode_session_history/
lib.rs

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