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