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