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