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