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