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