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::{
18        Arc, Mutex, Weak,
19        atomic::{AtomicBool, Ordering},
20    },
21};
22
23use anyhow::{Context as _, ensure};
24use chrono::{DateTime, Duration, Utc};
25use kcode_session_log::{EventPosition, Role, Session as DurableSession, SessionLog, SessionStore};
26use serde::{Deserialize, Serialize};
27use serde_json::{Map, Value, json};
28use sha2::{Digest, Sha256};
29use tokio::sync::Notify;
30use uuid::Uuid;
31
32const LIFECYCLE_SIDEBAND: &str = "session_lifecycle";
33const COMMAND_SIDEBAND: &str = "session_command";
34const STOP_SIDEBAND: &str = "session_stop";
35const CONTROL_EXTENSION: &str = "session-control";
36const INGRESS_FAILURE_LIMIT: i64 = 5;
37const INGRESS_RETRY_DELAY_SECONDS: i64 = 15;
38const RETAINED_INGRESS_FAILURES: usize = 5;
39
40#[derive(Clone, Debug, Deserialize, Serialize)]
41#[serde(rename_all = "camelCase")]
42struct ControlRecord {
43    kind: String,
44    recorded_at: String,
45    value: Value,
46}
47
48struct ControlJournal {
49    path: PathBuf,
50    records: Vec<ControlRecord>,
51}
52
53impl ControlJournal {
54    fn create(path: PathBuf) -> anyhow::Result<Self> {
55        let file = OpenOptions::new()
56            .create_new(true)
57            .write(true)
58            .open(&path)?;
59        file.sync_all()?;
60        sync_directory(path.parent().unwrap_or_else(|| FilePath::new(".")))?;
61        Ok(Self {
62            path,
63            records: Vec::new(),
64        })
65    }
66
67    fn open(path: PathBuf) -> anyhow::Result<Self> {
68        match Self::open_existing(path.clone())? {
69            Some(journal) => Ok(journal),
70            None => Self::create(path),
71        }
72    }
73
74    fn open_existing(path: PathBuf) -> anyhow::Result<Option<Self>> {
75        let mut file = match OpenOptions::new().read(true).write(true).open(&path) {
76            Ok(file) => file,
77            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
78            Err(error) => return Err(error.into()),
79        };
80        let mut bytes = Vec::new();
81        file.read_to_end(&mut bytes)?;
82        let mut records = Vec::new();
83        let mut cursor = 0_usize;
84        while cursor < bytes.len() {
85            let Some(relative_end) = bytes[cursor..].iter().position(|byte| *byte == b'\n') else {
86                file.set_len(cursor as u64)?;
87                file.sync_all()?;
88                break;
89            };
90            let end = cursor + relative_end;
91            let line = &bytes[cursor..end];
92            let separator = line
93                .iter()
94                .position(|byte| *byte == b' ')
95                .context("session-control record has no checksum separator")?;
96            let expected = std::str::from_utf8(&line[..separator])?;
97            let payload = &line[separator + 1..];
98            ensure!(
99                hex_sha256(payload) == expected,
100                "session-control record checksum mismatch"
101            );
102            records.push(serde_json::from_slice(payload)?);
103            cursor = end + 1;
104        }
105        Ok(Some(Self { path, records }))
106    }
107
108    fn records(&self) -> &[ControlRecord] {
109        &self.records
110    }
111
112    fn append(
113        &mut self,
114        kind: impl Into<String>,
115        recorded_at: impl Into<String>,
116        value: Value,
117    ) -> anyhow::Result<()> {
118        let record = ControlRecord {
119            kind: kind.into(),
120            recorded_at: recorded_at.into(),
121            value,
122        };
123        let payload = serde_json::to_vec(&record)?;
124        let mut file = OpenOptions::new().append(true).open(&self.path)?;
125        writeln!(
126            file,
127            "{} {}",
128            hex_sha256(&payload),
129            String::from_utf8(payload)?
130        )?;
131        file.sync_all()?;
132        self.records.push(record);
133        Ok(())
134    }
135}
136
137struct SessionJournal {
138    log: DurableSession,
139    control: ControlJournal,
140}
141
142impl SessionJournal {
143    fn create(directory: &FilePath, id: &str, created_at: &str) -> anyhow::Result<Self> {
144        let log = SessionStore::new(directory).create_session(id, created_at)?;
145        let control = ControlJournal::create(control_path(directory, id))?;
146        Ok(Self { log, control })
147    }
148
149    fn open(path: impl AsRef<FilePath>) -> anyhow::Result<Self> {
150        let path = path.as_ref();
151        ensure!(
152            path.extension().and_then(|value| value.to_str()) == Some("session-log"),
153            "{} is not a session-log path",
154            path.display()
155        );
156        let directory = path.parent().unwrap_or_else(|| FilePath::new("."));
157        let id = path
158            .file_stem()
159            .and_then(|value| value.to_str())
160            .context("session-log filename is not valid UTF-8")?;
161        Ok(Self {
162            log: SessionStore::new(directory).open_session(id)?,
163            control: ControlJournal::open(control_path(directory, id))?,
164        })
165    }
166
167    fn open_existing(path: impl AsRef<FilePath>) -> anyhow::Result<Option<Self>> {
168        let path = path.as_ref();
169        ensure!(
170            path.extension().and_then(|value| value.to_str()) == Some("session-log"),
171            "{} is not a session-log path",
172            path.display()
173        );
174        let directory = path.parent().unwrap_or_else(|| FilePath::new("."));
175        let id = path
176            .file_stem()
177            .and_then(|value| value.to_str())
178            .context("session-log filename is not valid UTF-8")?;
179        let log = match SessionStore::new(directory).open_session(id) {
180            Ok(log) => log,
181            Err(_) if !path.exists() => return Ok(None),
182            Err(error) => return Err(error),
183        };
184        let Some(control) = ControlJournal::open_existing(control_path(directory, id))? else {
185            return Ok(None);
186        };
187        Ok(Some(Self { log, control }))
188    }
189
190    fn list(&self) -> SessionLog {
191        self.log.list()
192    }
193
194    fn records(&self) -> &[ControlRecord] {
195        self.control.records()
196    }
197
198    fn append_control(
199        &mut self,
200        kind: impl Into<String>,
201        recorded_at: impl Into<String>,
202        value: Value,
203    ) -> anyhow::Result<()> {
204        self.control.append(kind, recorded_at, value)
205    }
206
207    fn stage_object(
208        &mut self,
209        media_type: String,
210        file_name: Option<String>,
211        bytes: &[u8],
212    ) -> anyhow::Result<String> {
213        let file_name = file_name.unwrap_or_else(|| "uploaded-object".into());
214        let position =
215            self.log
216                .add_pending_object(file_name.clone(), file_name, media_type, bytes)?;
217        Ok(format!("pending:{}", position.index() + 1))
218    }
219}
220
221fn control_path(directory: &FilePath, id: &str) -> PathBuf {
222    directory.join(format!("{id}.{CONTROL_EXTENSION}"))
223}
224
225fn hex_sha256(bytes: &[u8]) -> String {
226    Sha256::digest(bytes)
227        .iter()
228        .map(|byte| format!("{byte:02x}"))
229        .collect()
230}
231
232#[derive(Clone, Debug)]
233pub struct Config {
234    pub directory: PathBuf,
235    pub completed_list: PathBuf,
236    pub provider_cost_compatibility: Option<ProviderCostCompatibility>,
237}
238
239/// Application-owned callbacks used to price legacy provider receipts on replay.
240#[derive(Clone, Copy)]
241pub struct ProviderCostCompatibility {
242    pub session_model: fn(&Value) -> Option<String>,
243    pub estimator: chatend::ProviderCostEstimator,
244}
245
246impl std::fmt::Debug for ProviderCostCompatibility {
247    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248        formatter
249            .debug_struct("ProviderCostCompatibility")
250            .finish_non_exhaustive()
251    }
252}
253
254#[derive(Clone, Debug)]
255pub struct NewSession {
256    pub kind: chatend::SessionKind,
257    pub created_at: String,
258    pub effective_context_tokens: u64,
259    pub channel: Value,
260}
261
262#[derive(Clone)]
263struct AppState {
264    config: Config,
265    catalog_mutation: Arc<Mutex<()>>,
266    session_mutations: Arc<Mutex<HashMap<String, Weak<Mutex<()>>>>>,
267    stop_listeners: Arc<Mutex<HashMap<String, Weak<StopSignal>>>>,
268}
269
270#[derive(Clone)]
271pub struct SessionHistory {
272    state: AppState,
273}
274
275struct StopSignal {
276    requested: AtomicBool,
277    notification: Notify,
278}
279
280/// A same-process notification that durable work for one session should stop.
281///
282/// Session History owns the durable request. The orchestration owner listens
283/// here only to begin unwinding live work promptly; a listener created after a
284/// request was recorded is signaled from that durable request.
285#[derive(Clone)]
286pub struct StopListener {
287    signal: Arc<StopSignal>,
288}
289
290impl StopListener {
291    pub async fn requested(&self) {
292        loop {
293            let notified = self.signal.notification.notified();
294            tokio::pin!(notified);
295            notified.as_mut().enable();
296            if self.signal.requested.load(Ordering::Acquire) {
297                return;
298            }
299            notified.await;
300        }
301    }
302}
303
304#[derive(Debug)]
305pub struct Error {
306    pub kind: ErrorKind,
307    pub message: String,
308}
309
310#[derive(Clone, Copy, Debug, Eq, PartialEq)]
311pub enum ErrorKind {
312    InvalidInput,
313    NotFound,
314    Conflict,
315    Storage,
316}
317
318impl ErrorKind {
319    pub fn code(self) -> &'static str {
320        match self {
321            Self::InvalidInput => "invalid_request",
322            Self::NotFound => "not_found",
323            Self::Conflict => "state_conflict",
324            Self::Storage => "internal_error",
325        }
326    }
327}
328
329impl std::fmt::Display for Error {
330    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331        formatter.write_str(&self.message)
332    }
333}
334
335impl std::error::Error for Error {}
336
337impl From<ApiError> for Error {
338    fn from(error: ApiError) -> Self {
339        Self {
340            kind: error.kind,
341            message: error.message,
342        }
343    }
344}
345
346#[derive(Debug)]
347struct ApiError {
348    kind: ErrorKind,
349    message: String,
350}
351
352impl ApiError {
353    fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
354        Self {
355            kind,
356            message: message.into(),
357        }
358    }
359
360    fn bad(message: impl Into<String>) -> Self {
361        Self::new(ErrorKind::InvalidInput, message)
362    }
363
364    fn not_found() -> Self {
365        Self::new(ErrorKind::NotFound, "Session not found.")
366    }
367
368    fn conflict(message: impl Into<String>) -> Self {
369        Self::new(ErrorKind::Conflict, message)
370    }
371
372    fn internal(error: impl std::fmt::Display) -> Self {
373        tracing::warn!(error=%format!("{error:#}"), "Session History request failed");
374        Self::new(
375            ErrorKind::Storage,
376            "An unexpected Session History storage error occurred.",
377        )
378    }
379}
380
381#[derive(Clone, Debug, Deserialize, Serialize)]
382pub struct SessionRecord {
383    pub id: String,
384    pub phase: String,
385    pub started_at: String,
386    pub updated_at: String,
387    pub state: Value,
388    pub provenance_id: Option<String>,
389    pub version: i64,
390    pub last_user_message_at: Option<String>,
391    pub ended_at: Option<String>,
392    pub ingress_failure_count: i64,
393    pub ingress_failures: Value,
394    pub ingress_next_attempt_at: Option<String>,
395    #[serde(default, skip_serializing_if = "is_false")]
396    pub summary: bool,
397}
398
399fn is_false(value: &bool) -> bool {
400    !*value
401}
402
403#[derive(Clone, Debug, Deserialize, Serialize)]
404pub struct RegisterSession {
405    pub id: String,
406    pub started_at: String,
407    pub state: Value,
408}
409
410#[derive(Clone, Debug, Deserialize, Serialize)]
411pub struct StartSession {
412    pub idempotency_id: String,
413    pub started_at: String,
414    pub session_type: String,
415    #[serde(default)]
416    pub duration_minutes: Option<f64>,
417    #[serde(default)]
418    pub custom_prompt: Option<String>,
419}
420
421#[derive(Clone, Debug, Deserialize, Serialize)]
422pub struct NewIngressSession {
423    pub idempotency_id: String,
424    pub started_at: String,
425    pub source_session_type: String,
426    pub kind: chatend::SessionKind,
427    pub effective_context_tokens: u64,
428    pub text: String,
429    #[serde(default)]
430    pub metadata: Value,
431}
432
433#[derive(Clone, Debug, Deserialize, Serialize)]
434pub struct NewCommand {
435    pub idempotency_id: String,
436    pub kind: String,
437    #[serde(default = "empty_object")]
438    pub payload: Value,
439}
440
441fn empty_object() -> Value {
442    json!({})
443}
444
445#[derive(Clone, Debug, Deserialize, Serialize)]
446pub struct CommandOutcome {
447    #[serde(default = "empty_object")]
448    pub outcome: Value,
449}
450
451#[derive(Clone, Debug, Deserialize, Serialize)]
452pub struct NewStopRequest {
453    pub idempotency_id: String,
454    pub scope: String,
455}
456
457#[derive(Clone, Debug, Deserialize, Serialize)]
458pub struct NewCurrentWorkStop {
459    pub idempotency_id: String,
460}
461
462#[derive(Clone, Debug, Deserialize, Serialize)]
463pub struct StopOutcome {
464    #[serde(default = "empty_object")]
465    pub outcome: Value,
466}
467
468#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
469#[serde(rename_all = "camelCase")]
470pub struct SessionStopRequest {
471    pub id: String,
472    pub session_id: String,
473    pub scope: String,
474    pub status: String,
475    pub outcome: Option<Value>,
476    pub requested_at: String,
477    pub completed_at: Option<String>,
478    pub idempotency_id: String,
479}
480
481#[derive(Clone, Debug, Deserialize, Serialize)]
482#[serde(rename_all = "camelCase")]
483pub struct SessionCommand {
484    pub id: String,
485    pub conversation_id: String,
486    pub sequence: i64,
487    pub kind: String,
488    pub payload: Value,
489    pub status: String,
490    pub cancel_requested: bool,
491    pub outcome: Option<Value>,
492    pub created_at: String,
493    pub processing_started_at: Option<String>,
494    pub completed_at: Option<String>,
495    pub idempotency_id: String,
496}
497
498#[derive(Clone, Debug, Deserialize, Serialize)]
499pub struct Checkpoint {
500    pub expected_version: i64,
501    pub state: Value,
502    #[serde(default)]
503    pub user_activity: bool,
504}
505
506#[derive(Clone, Debug, Deserialize, Serialize)]
507pub struct ExpectedVersion {
508    pub expected_version: i64,
509}
510
511#[derive(Clone, Debug, Deserialize, Serialize)]
512pub struct RetryIngress {
513    pub expected_version: i64,
514    pub state: Value,
515}
516
517#[derive(Clone, Debug, Deserialize, Serialize)]
518pub struct StartIngress {
519    pub expected_version: i64,
520    pub provenance_id: String,
521}
522
523#[derive(Clone, Debug, Deserialize, Serialize)]
524pub struct IngressFailure {
525    pub expected_version: i64,
526    pub stage: String,
527    #[serde(default)]
528    pub code: Option<String>,
529    pub message: String,
530    #[serde(default)]
531    pub rounds_used: Option<u64>,
532    #[serde(default)]
533    pub context_tokens: Option<u64>,
534    #[serde(default)]
535    pub context_window_tokens: Option<u64>,
536}
537
538#[derive(Clone, Debug, Deserialize, Serialize)]
539pub struct RecordCompletion {
540    pub session_object_id: String,
541    #[serde(default)]
542    pub commit_receipt: Option<CompletionReceipt>,
543    #[serde(default)]
544    pub session_id: Option<String>,
545    #[serde(default)]
546    pub session_type: Option<String>,
547    #[serde(default)]
548    pub created_at: Option<String>,
549}
550
551#[derive(Clone, Debug, Deserialize, Serialize)]
552#[serde(rename_all = "camelCase")]
553pub struct CompletionReceipt {
554    #[serde(default)]
555    pub transaction_id: Option<String>,
556    pub session_object_id: String,
557    #[serde(default)]
558    pub session_id: Option<String>,
559    #[serde(default)]
560    pub session_type: Option<String>,
561    #[serde(default)]
562    pub created_at: Option<String>,
563    #[serde(default)]
564    pub committed_at: Option<String>,
565    #[serde(default)]
566    pub ingress_source: Option<Value>,
567    #[serde(default)]
568    pub node_ids: BTreeMap<String, String>,
569    #[serde(default)]
570    pub object_ids: BTreeMap<String, String>,
571}
572
573#[derive(Clone, Debug, Eq, PartialEq)]
574pub struct Created<T> {
575    pub value: T,
576    pub created: bool,
577}
578
579#[derive(Clone, Debug)]
580pub struct NewObject {
581    pub file_name: Option<String>,
582    pub media_type: String,
583    pub bytes: Vec<u8>,
584}
585
586#[derive(Clone, Debug)]
587pub struct StoredObject {
588    pub file_name: String,
589    pub media_type: String,
590    pub bytes: Vec<u8>,
591}
592
593impl SessionHistory {
594    pub fn open(config: Config) -> anyhow::Result<Self> {
595        create_private_directory(&config.directory)?;
596        compact_control_journals(&config.directory)?;
597        if let Some(parent) = config
598            .completed_list
599            .parent()
600            .filter(|path| !path.as_os_str().is_empty())
601        {
602            create_private_directory(parent)?;
603        }
604        if !config.completed_list.exists() {
605            let file = OpenOptions::new()
606                .create_new(true)
607                .write(true)
608                .open(&config.completed_list)
609                .with_context(|| format!("creating {}", config.completed_list.display()))?;
610            file.sync_all()?;
611            sync_directory(
612                config
613                    .completed_list
614                    .parent()
615                    .filter(|path| !path.as_os_str().is_empty())
616                    .unwrap_or_else(|| FilePath::new(".")),
617            )?;
618        }
619        Ok(Self {
620            state: AppState {
621                config,
622                catalog_mutation: Arc::new(Mutex::new(())),
623                session_mutations: Arc::new(Mutex::new(HashMap::new())),
624                stop_listeners: Arc::new(Mutex::new(HashMap::new())),
625            },
626        })
627    }
628
629    pub fn health(&self) -> Result<(), Error> {
630        read_completed_ids(&self.state.config.completed_list).map_err(ApiError::internal)?;
631        Ok(())
632    }
633
634    pub fn create_session(&self, input: NewSession) -> anyhow::Result<Session> {
635        let metadata = chatend::SessionMetadata {
636            session_id: Uuid::new_v4().to_string(),
637            kind: input.kind,
638            created_at: input.created_at,
639            effective_context_tokens: input.effective_context_tokens,
640            channel: input.channel,
641        };
642        Session::create(
643            self.state
644                .config
645                .directory
646                .join(format!("{}.session-log", metadata.session_id)),
647            metadata,
648        )
649    }
650
651    pub fn open_session(&self, metadata: chatend::SessionMetadata) -> anyhow::Result<Session> {
652        self.open_session_with_provider_model(metadata, None)
653    }
654
655    pub fn open_session_with_provider_model(
656        &self,
657        metadata: chatend::SessionMetadata,
658        provider_model: Option<&str>,
659    ) -> anyhow::Result<Session> {
660        validate_session_id(&metadata.session_id)
661            .map_err(|error| anyhow::anyhow!(error.message))?;
662        let path = self
663            .state
664            .config
665            .directory
666            .join(format!("{}.session-log", metadata.session_id));
667        match self.state.config.provider_cost_compatibility {
668            Some(compatibility) => Session::open_with_metadata_and_provider_costs(
669                path,
670                metadata,
671                provider_model,
672                Some(compatibility.estimator),
673            ),
674            None => Session::open_with_metadata(path, metadata),
675        }
676    }
677
678    /// Reconstructs only the display-cost fields for an immutable session archive.
679    ///
680    /// The configured compatibility callbacks own model recovery and pricing. The
681    /// archive and its checksummed event stream are never modified. Metadata-free
682    /// session-log archives predate this Chatend projection and return `None`.
683    pub fn legacy_provider_cost_summary_for_archive(
684        &self,
685        archive: &Value,
686        session_state: Option<&Value>,
687    ) -> anyhow::Result<Option<chatend::ProviderCostSummary>> {
688        let Some(compatibility) = self.state.config.provider_cost_compatibility else {
689            return Ok(None);
690        };
691        if is_metadata_free_session_log_archive(archive) {
692            return Ok(None);
693        }
694        let default_provider_model =
695            session_state.and_then(|state| (compatibility.session_model)(state));
696        chatend::legacy_provider_cost_summary_for_archive(
697            archive,
698            default_provider_model.as_deref(),
699            compatibility.estimator,
700        )
701        .map(Some)
702    }
703
704    pub async fn register(&self, input: RegisterSession) -> Result<SessionRecord, Error> {
705        create_session(self.state.clone(), input)
706            .await
707            .map_err(Into::into)
708    }
709
710    pub async fn start(&self, input: StartSession) -> Result<Created<SessionRecord>, Error> {
711        let (created, record) = start_managed_session(self.state.clone(), input).await?;
712        Ok(Created {
713            value: record,
714            created,
715        })
716    }
717
718    pub async fn enqueue_ingress(
719        &self,
720        input: NewIngressSession,
721    ) -> Result<Created<SessionRecord>, Error> {
722        let (created, record) = enqueue_ingress_session(self.state.clone(), input).await?;
723        Ok(Created {
724            value: record,
725            created,
726        })
727    }
728
729    pub async fn list(&self) -> Result<Vec<SessionRecord>, Error> {
730        list_session_summaries(self.state.clone())
731            .await
732            .map_err(Into::into)
733    }
734
735    pub async fn get(&self, id: &str) -> Result<SessionRecord, Error> {
736        get_session(self.state.clone(), id.to_owned())
737            .await
738            .map_err(Into::into)
739    }
740
741    pub async fn enqueue(
742        &self,
743        id: &str,
744        input: NewCommand,
745    ) -> Result<Created<SessionCommand>, Error> {
746        let (created, command) =
747            queue_session_command(self.state.clone(), id.to_owned(), input).await?;
748        Ok(Created {
749            value: command,
750            created,
751        })
752    }
753
754    pub async fn command_heads(&self) -> Result<Vec<SessionCommand>, Error> {
755        list_command_heads(self.state.clone())
756            .await
757            .map_err(Into::into)
758    }
759
760    pub async fn claim_command(&self, id: &str) -> Result<SessionCommand, Error> {
761        claim_command(self.state.clone(), id.to_owned())
762            .await
763            .map_err(Into::into)
764    }
765
766    pub async fn complete_command(
767        &self,
768        id: &str,
769        outcome: CommandOutcome,
770    ) -> Result<SessionCommand, Error> {
771        complete_command(self.state.clone(), id.to_owned(), outcome)
772            .await
773            .map_err(Into::into)
774    }
775
776    pub async fn request_stop(
777        &self,
778        id: &str,
779        input: NewStopRequest,
780    ) -> Result<Created<SessionStopRequest>, Error> {
781        let (created, request) = request_session_stop(
782            self.state.clone(),
783            id.to_owned(),
784            input.idempotency_id,
785            Some(input.scope),
786        )
787        .await?;
788        signal_stop_listener(&self.state, id).map_err(Error::from)?;
789        Ok(Created {
790            value: request,
791            created,
792        })
793    }
794
795    /// Requests the stop appropriate to the session's current durable work.
796    ///
797    /// Interactive sessions stop only their current turn, self-time stops its
798    /// current run, and all other work stops through ordinary session
799    /// completion. Scope derivation, queued-command cancellation, durable
800    /// request creation, and live-listener notification are one owner-level
801    /// operation.
802    pub async fn request_current_work_stop(
803        &self,
804        id: &str,
805        input: NewCurrentWorkStop,
806    ) -> Result<Created<SessionStopRequest>, Error> {
807        let (created, request) = request_session_stop(
808            self.state.clone(),
809            id.to_owned(),
810            input.idempotency_id,
811            None,
812        )
813        .await?;
814        signal_stop_listener(&self.state, id).map_err(Error::from)?;
815        Ok(Created {
816            value: request,
817            created,
818        })
819    }
820
821    /// Registers the orchestration owner for prompt notification of a stop.
822    ///
823    /// Registration is restart-safe: an already-pending durable request
824    /// signals the returned listener before this method returns.
825    pub fn listen_for_stop(&self, id: &str) -> Result<StopListener, Error> {
826        listen_for_stop(&self.state, id).map_err(Into::into)
827    }
828
829    pub async fn stop_heads(&self) -> Result<Vec<SessionStopRequest>, Error> {
830        list_stop_heads(self.state.clone())
831            .await
832            .map_err(Into::into)
833    }
834
835    pub async fn complete_stop(
836        &self,
837        id: &str,
838        outcome: StopOutcome,
839    ) -> Result<SessionStopRequest, Error> {
840        complete_stop_request(self.state.clone(), id.to_owned(), outcome)
841            .await
842            .map_err(Into::into)
843    }
844
845    pub async fn stage_object(&self, id: &str, object: NewObject) -> Result<String, Error> {
846        stage_session_object(&self.state, id, object).map_err(Into::into)
847    }
848
849    pub fn object(&self, id: &str, pending_id: &str) -> Result<StoredObject, Error> {
850        get_session_object(&self.state, id, pending_id).map_err(Into::into)
851    }
852
853    pub async fn checkpoint(&self, id: &str, input: Checkpoint) -> Result<SessionRecord, Error> {
854        let record = checkpoint(
855            self.state.clone(),
856            id,
857            input.expected_version,
858            input.state,
859            input.user_activity,
860        )
861        .await?;
862        Ok(record)
863    }
864
865    pub async fn request_ingress(
866        &self,
867        id: &str,
868        input: Checkpoint,
869    ) -> Result<SessionRecord, Error> {
870        let record =
871            transition_with_checkpoint(self.state.clone(), id, input, "ingress_pending").await?;
872        Ok(record)
873    }
874
875    pub async fn start_ingress(
876        &self,
877        id: &str,
878        input: StartIngress,
879    ) -> Result<SessionRecord, Error> {
880        let record = transition(
881            self.state.clone(),
882            id,
883            input.expected_version,
884            "ingress_in_progress",
885            Some(input.provenance_id),
886        )
887        .await?;
888        Ok(record)
889    }
890
891    pub async fn complete_ingress(
892        &self,
893        id: &str,
894        input: ExpectedVersion,
895    ) -> Result<SessionRecord, Error> {
896        let current = fetch_active(&self.state, id)?;
897        let record = complete_session(
898            self.state.clone(),
899            id,
900            input.expected_version,
901            current.state,
902        )
903        .await?;
904        Ok(record)
905    }
906
907    pub async fn fail_ingress(
908        &self,
909        id: &str,
910        input: IngressFailure,
911    ) -> Result<SessionRecord, Error> {
912        let record = record_ingress_failure(self.state.clone(), id, input).await?;
913        Ok(record)
914    }
915
916    pub async fn retry_ingress(
917        &self,
918        id: &str,
919        input: RetryIngress,
920    ) -> Result<SessionRecord, Error> {
921        retry_ingress(self.state.clone(), id.to_owned(), input)
922            .await
923            .map_err(Into::into)
924    }
925
926    pub async fn release_interrupted_ingress(&self) -> Result<Vec<String>, Error> {
927        release_ingress_repairs(self.state.clone())
928            .await
929            .map_err(Into::into)
930    }
931
932    pub async fn complete(&self, id: &str, input: Checkpoint) -> Result<SessionRecord, Error> {
933        let record =
934            complete_session(self.state.clone(), id, input.expected_version, input.state).await?;
935        Ok(record)
936    }
937
938    pub async fn record_completion(&self, input: RecordCompletion) -> Result<(), Error> {
939        record_completed_session(self.state.clone(), input).await?;
940        Ok(())
941    }
942}
943
944fn is_metadata_free_session_log_archive(archive: &Value) -> bool {
945    if archive.get("metadata").is_some() {
946        return false;
947    }
948    let Some(header) = archive.get("header") else {
949        return false;
950    };
951    if header.get("formatVersion").and_then(Value::as_str)
952        != Some(kcode_session_log::FORMAT_VERSION)
953        || !header
954            .get("sessionId")
955            .and_then(Value::as_str)
956            .is_some_and(|value| !value.trim().is_empty())
957        || !header
958            .get("createdAt")
959            .and_then(Value::as_str)
960            .is_some_and(|value| !value.trim().is_empty())
961    {
962        return false;
963    }
964    archive
965        .get("events")
966        .and_then(Value::as_array)
967        .is_some_and(|events| {
968            events.iter().all(|event| {
969                event.get("text").and_then(Value::as_str).is_some()
970                    && event
971                        .get("role")
972                        .and_then(Value::as_str)
973                        .is_some_and(|role| {
974                            matches!(
975                                role,
976                                "system-message"
977                                    | "system-error"
978                                    | "user-message"
979                                    | "kennedy-message"
980                                    | "kennedy-tool-call"
981                                    | "tool-result"
982                                    | "tool-error"
983                                    | "object"
984                                    | "pending-object"
985                            )
986                        })
987            })
988        })
989}
990
991async fn record_completed_session(
992    state: AppState,
993    input: RecordCompletion,
994) -> Result<Value, ApiError> {
995    let session_guard = input
996        .session_id
997        .as_deref()
998        .map(|id| session_mutation(&state, id))
999        .transpose()?;
1000    let _session_guard = session_guard
1001        .as_ref()
1002        .map(|guard| guard.lock().map_err(ApiError::internal))
1003        .transpose()?;
1004    let _catalog_guard = state.catalog_mutation.lock().map_err(ApiError::internal)?;
1005    let mut receipt = input.commit_receipt.unwrap_or(CompletionReceipt {
1006        transaction_id: None,
1007        session_object_id: input.session_object_id.clone(),
1008        session_id: None,
1009        session_type: None,
1010        created_at: None,
1011        committed_at: None,
1012        ingress_source: None,
1013        node_ids: BTreeMap::new(),
1014        object_ids: BTreeMap::new(),
1015    });
1016    if receipt.session_object_id != input.session_object_id {
1017        return Err(ApiError::conflict(
1018            "completion receipt and requested session object differ",
1019        ));
1020    }
1021    receipt.session_id = receipt.session_id.or(input.session_id.clone());
1022    receipt.session_type = receipt.session_type.or(input.session_type);
1023    receipt.created_at = receipt.created_at.or(input.created_at);
1024    receipt.committed_at.get_or_insert_with(now);
1025    append_completion_receipt(&state.config.completed_list, &receipt)
1026        .map_err(ApiError::internal)?;
1027    if let Some(id) = input.session_id {
1028        validate_session_id(&id)?;
1029        let path = state.config.directory.join(format!("{id}.session-log"));
1030        if path.exists() {
1031            SessionJournal::open(&path)
1032                .map_err(ApiError::internal)?
1033                .log
1034                .delete_committed()
1035                .map_err(ApiError::internal)?;
1036            let control = control_path(&state.config.directory, &id);
1037            if control.exists() {
1038                std::fs::remove_file(&control)
1039                    .with_context(|| format!("removing {}", control.display()))
1040                    .map_err(ApiError::internal)?;
1041                sync_directory(&state.config.directory).map_err(ApiError::internal)?;
1042            }
1043        }
1044    }
1045    Ok(json!({
1046        "sessionObjectId":input.session_object_id,
1047        "recorded":true
1048    }))
1049}
1050
1051async fn create_session(
1052    state: AppState,
1053    input: RegisterSession,
1054) -> Result<SessionRecord, ApiError> {
1055    validate_started_at(&input.started_at)?;
1056    validate_session_id(&input.id)?;
1057    let path = state
1058        .config
1059        .directory
1060        .join(format!("{}.session-log", input.id));
1061    let journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
1062    let id = journal.list().header.session_id;
1063    if id != input.id {
1064        return Err(ApiError::bad(
1065            "registered session ID does not match its durable session log",
1066        ));
1067    }
1068    drop(journal);
1069    let session_guard = session_mutation(&state, &id)?;
1070    let _guard = session_guard.lock().map_err(ApiError::internal)?;
1071    let mut journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
1072    if latest_lifecycle(&journal).is_some() {
1073        return Err(ApiError::conflict("Session is already registered."));
1074    }
1075    let record = SessionRecord {
1076        id,
1077        phase: "active".into(),
1078        started_at: input.started_at.clone(),
1079        updated_at: input.started_at,
1080        state: control_state(&input.state),
1081        provenance_id: None,
1082        version: 1,
1083        last_user_message_at: None,
1084        ended_at: None,
1085        ingress_failure_count: 0,
1086        ingress_failures: json!([]),
1087        ingress_next_attempt_at: None,
1088        summary: false,
1089    };
1090    append_lifecycle(&mut journal, &record)?;
1091    Ok(materialize(
1092        record,
1093        &journal,
1094        state.config.provider_cost_compatibility,
1095    ))
1096}
1097
1098async fn start_managed_session(
1099    state: AppState,
1100    input: StartSession,
1101) -> Result<(bool, SessionRecord), ApiError> {
1102    validate_started_at(&input.started_at)?;
1103    validate_idempotency(&input.idempotency_id)?;
1104    let _catalog_guard = state.catalog_mutation.lock().map_err(ApiError::internal)?;
1105    for path in journal_paths(&state.config.directory)? {
1106        let journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
1107        if let Some(record) = latest_lifecycle(&journal)
1108            && record
1109                .state
1110                .get("startIdempotencyId")
1111                .and_then(Value::as_str)
1112                == Some(&input.idempotency_id)
1113        {
1114            return Ok((
1115                false,
1116                materialize(record, &journal, state.config.provider_cost_compatibility),
1117            ));
1118        }
1119    }
1120    let id = Uuid::new_v4().to_string();
1121    let mut journal = SessionJournal::create(&state.config.directory, &id, &input.started_at)
1122        .map_err(ApiError::internal)?;
1123    let mut session_state = json!({
1124        "stateVersion":3,
1125        "sessionId":id,
1126        "sessionType":input.session_type,
1127        "startedAt":input.started_at,
1128        "startIdempotencyId":input.idempotency_id,
1129        "orchestration":{"owner":"backend","status":"idle"},
1130    });
1131    if input.session_type == "free-time" {
1132        session_state["selfTimeIntent"] = json!({
1133            "requestedAt":input.started_at,
1134            "durationMinutes":input.duration_minutes,
1135            "customPrompt":input.custom_prompt.unwrap_or_default(),
1136        });
1137    }
1138    let record = SessionRecord {
1139        id,
1140        phase: "active".into(),
1141        started_at: input.started_at.clone(),
1142        updated_at: input.started_at,
1143        state: session_state,
1144        provenance_id: None,
1145        version: 1,
1146        last_user_message_at: None,
1147        ended_at: None,
1148        ingress_failure_count: 0,
1149        ingress_failures: json!([]),
1150        ingress_next_attempt_at: None,
1151        summary: false,
1152    };
1153    append_lifecycle(&mut journal, &record)?;
1154    Ok((
1155        true,
1156        materialize(record, &journal, state.config.provider_cost_compatibility),
1157    ))
1158}
1159
1160async fn enqueue_ingress_session(
1161    state: AppState,
1162    input: NewIngressSession,
1163) -> Result<(bool, SessionRecord), ApiError> {
1164    validate_started_at(&input.started_at)?;
1165    validate_idempotency(&input.idempotency_id)?;
1166    if input.source_session_type.trim().is_empty() {
1167        return Err(ApiError::bad("Source session type must not be empty."));
1168    }
1169    if input.text.trim().is_empty() {
1170        return Err(ApiError::bad("Ingress source text must not be empty."));
1171    }
1172    let _catalog_guard = state.catalog_mutation.lock().map_err(ApiError::internal)?;
1173    for path in journal_paths(&state.config.directory)? {
1174        let journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
1175        if let Some(record) = latest_lifecycle(&journal)
1176            && ingress_idempotency_id(record.state.get("ingressSource"))
1177                == Some(input.idempotency_id.as_str())
1178        {
1179            return Ok((
1180                false,
1181                materialize(record, &journal, state.config.provider_cost_compatibility),
1182            ));
1183        }
1184    }
1185    for receipt in
1186        read_completion_receipts(&state.config.completed_list).map_err(ApiError::internal)?
1187    {
1188        if ingress_idempotency_id(receipt.ingress_source.as_ref())
1189            == Some(input.idempotency_id.as_str())
1190        {
1191            return Ok((false, completed_session_record(receipt, false)));
1192        }
1193    }
1194
1195    let id = Uuid::new_v4().to_string();
1196    let path = state.config.directory.join(format!("{id}.session-log"));
1197    let mut source = Session::create(
1198        &path,
1199        chatend::SessionMetadata {
1200            session_id: id.clone(),
1201            kind: input.kind,
1202            created_at: input.started_at.clone(),
1203            effective_context_tokens: input.effective_context_tokens,
1204            channel: Value::Null,
1205        },
1206    )
1207    .map_err(ApiError::internal)?;
1208    source
1209        .create_box(
1210            input.started_at.clone(),
1211            "Ingress source",
1212            chatend::BoxOwner::User,
1213            chatend::BoxContent {
1214                text: input.text.trim().to_owned(),
1215                objects: Vec::new(),
1216                metadata: input.metadata.clone(),
1217            },
1218        )
1219        .map_err(ApiError::internal)?;
1220    let chatend_metadata = source.state().metadata.clone();
1221    drop(source);
1222
1223    let ingress_source = json!({
1224        "idempotencyId":input.idempotency_id,
1225        "metadata":input.metadata,
1226    });
1227    let mut journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
1228    let record = SessionRecord {
1229        id: id.clone(),
1230        phase: "ingress_pending".into(),
1231        started_at: input.started_at.clone(),
1232        updated_at: input.started_at.clone(),
1233        state: json!({
1234            "stateVersion":3,
1235            "sessionId":id,
1236            "sessionType":input.source_session_type,
1237            "startedAt":input.started_at,
1238            "ingressSource":ingress_source,
1239            "chatendMetadata":chatend_metadata,
1240        }),
1241        provenance_id: None,
1242        version: 1,
1243        last_user_message_at: None,
1244        ended_at: None,
1245        ingress_failure_count: 0,
1246        ingress_failures: json!([]),
1247        ingress_next_attempt_at: None,
1248        summary: false,
1249    };
1250    append_lifecycle(&mut journal, &record)?;
1251    Ok((
1252        true,
1253        materialize(record, &journal, state.config.provider_cost_compatibility),
1254    ))
1255}
1256
1257fn ingress_idempotency_id(source: Option<&Value>) -> Option<&str> {
1258    source?.get("idempotencyId").and_then(Value::as_str)
1259}
1260
1261async fn list_session_summaries(state: AppState) -> Result<Vec<SessionRecord>, ApiError> {
1262    let mut sessions = Vec::new();
1263    for journal in open_listed_journals(journal_paths(&state.config.directory)?)? {
1264        if let Some(mut record) = latest_lifecycle(&journal) {
1265            record.summary = true;
1266            record.state = summary_state(&record.state, &journal);
1267            sessions.push(record);
1268        }
1269    }
1270    let receipts =
1271        read_completion_receipts(&state.config.completed_list).map_err(ApiError::internal)?;
1272    let completed_session_ids = receipts
1273        .iter()
1274        .filter_map(|receipt| receipt.session_id.as_ref())
1275        .collect::<HashSet<_>>();
1276    sessions.retain(|session| !completed_session_ids.contains(&session.id));
1277    for receipt in receipts {
1278        sessions.push(completed_session_record(receipt, true));
1279    }
1280    sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
1281    Ok(sessions)
1282}
1283
1284async fn get_session(state: AppState, id: String) -> Result<SessionRecord, ApiError> {
1285    if let Some(receipt) = read_completion_receipts(&state.config.completed_list)
1286        .map_err(ApiError::internal)?
1287        .into_iter()
1288        .find(|receipt| receipt.session_object_id == id)
1289    {
1290        return Ok(completed_session_record(receipt, false));
1291    }
1292    let journal = open_by_id(&state, &id)?;
1293    let record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1294    Ok(materialize(
1295        record,
1296        &journal,
1297        state.config.provider_cost_compatibility,
1298    ))
1299}
1300
1301fn completed_session_record(receipt: CompletionReceipt, summary: bool) -> SessionRecord {
1302    let object_id = receipt.session_object_id.clone();
1303    let started_at = receipt.created_at.clone().unwrap_or_default();
1304    let updated_at = receipt.committed_at.clone().unwrap_or_default();
1305    SessionRecord {
1306        id: object_id.clone(),
1307        phase: "complete".into(),
1308        started_at,
1309        updated_at,
1310        state: json!({
1311            "sessionObjectId":object_id,
1312            "sessionId":receipt.session_id.clone(),
1313            "sessionType":receipt.session_type.clone(),
1314            "ingressSource":receipt.ingress_source.clone(),
1315            "commitReceipt":receipt,
1316        }),
1317        provenance_id: None,
1318        version: 1,
1319        last_user_message_at: None,
1320        ended_at: None,
1321        ingress_failure_count: 0,
1322        ingress_failures: json!([]),
1323        ingress_next_attempt_at: None,
1324        summary,
1325    }
1326}
1327
1328fn get_session_object(
1329    state: &AppState,
1330    id: &str,
1331    pending_id: &str,
1332) -> Result<StoredObject, ApiError> {
1333    let number = pending_id
1334        .strip_prefix("pending:")
1335        .and_then(|value| value.parse::<u64>().ok())
1336        .filter(|value| *value > 0)
1337        .ok_or_else(|| ApiError::bad("Object ID must have the form pending:N."))?;
1338    let journal = open_by_id(state, id)?;
1339    let object = journal
1340        .log
1341        .read_pending_object(EventPosition(number - 1))
1342        .map_err(|error| {
1343            tracing::warn!(session_id=id, pending_id, %error, "pending session object is unavailable");
1344            ApiError::not_found()
1345        })?;
1346    let media_type = if object.media_type.trim().is_empty()
1347        || object
1348            .media_type
1349            .chars()
1350            .any(|character| character.is_control() || character.is_whitespace())
1351        || !object.media_type.contains('/')
1352    {
1353        "application/octet-stream"
1354    } else {
1355        &object.media_type
1356    };
1357    let mut file_name = object
1358        .file_name
1359        .chars()
1360        .map(|character| {
1361            if character.is_ascii_graphic() && !matches!(character, '"' | '\\' | '/' | ';') {
1362                character
1363            } else {
1364                '_'
1365            }
1366        })
1367        .take(255)
1368        .collect::<String>();
1369    if file_name.is_empty() {
1370        file_name = "uploaded-object".into();
1371    }
1372    Ok(StoredObject {
1373        file_name,
1374        media_type: media_type.to_owned(),
1375        bytes: object.bytes,
1376    })
1377}
1378
1379async fn queue_session_command(
1380    state: AppState,
1381    id: String,
1382    input: NewCommand,
1383) -> Result<(bool, SessionCommand), ApiError> {
1384    validate_idempotency(&input.idempotency_id)?;
1385    let session_guard = session_mutation(&state, &id)?;
1386    let _guard = session_guard.lock().map_err(ApiError::internal)?;
1387    let mut journal = open_by_id(&state, &id)?;
1388    let record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1389    if record.phase != "active" {
1390        return Err(ApiError::conflict("Session is no longer active."));
1391    }
1392    let commands = commands(&journal);
1393    if let Some(command) = commands
1394        .values()
1395        .find(|command| command.idempotency_id == input.idempotency_id)
1396    {
1397        return Ok((false, command.clone()));
1398    }
1399    let command = SessionCommand {
1400        id: Uuid::new_v4().to_string(),
1401        conversation_id: id,
1402        sequence: commands
1403            .values()
1404            .map(|command| command.sequence)
1405            .max()
1406            .unwrap_or(0)
1407            + 1,
1408        kind: input.kind,
1409        payload: input.payload,
1410        status: "pending".into(),
1411        cancel_requested: false,
1412        outcome: None,
1413        created_at: now(),
1414        processing_started_at: None,
1415        completed_at: None,
1416        idempotency_id: input.idempotency_id,
1417    };
1418    append_command(&mut journal, &command)?;
1419    Ok((true, command))
1420}
1421
1422fn stage_session_object(state: &AppState, id: &str, object: NewObject) -> Result<String, ApiError> {
1423    let NewObject {
1424        file_name,
1425        media_type,
1426        bytes,
1427    } = object;
1428    let session_guard = session_mutation(state, id)?;
1429    let _guard = session_guard.lock().map_err(ApiError::internal)?;
1430    let mut journal = open_by_id(state, id)?;
1431    let record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1432    if record.phase != "active" {
1433        return Err(ApiError::conflict(
1434            "Objects can only be supplied to an active session.",
1435        ));
1436    }
1437    if commands(&journal)
1438        .values()
1439        .any(|command| matches!(command.status.as_str(), "pending" | "processing"))
1440    {
1441        return Err(ApiError::conflict(
1442            "Objects cannot be supplied while the session is processing a command.",
1443        ));
1444    }
1445    let pending_id = journal
1446        .stage_object(media_type, file_name, &bytes)
1447        .map_err(|error| ApiError::bad(error.to_string()))?;
1448    Ok(pending_id)
1449}
1450
1451async fn list_command_heads(state: AppState) -> Result<Vec<SessionCommand>, ApiError> {
1452    let mut heads = Vec::new();
1453    for journal in open_listed_journals(journal_paths(&state.config.directory)?)? {
1454        let mut active = commands(&journal)
1455            .into_values()
1456            .filter(|command| matches!(command.status.as_str(), "pending" | "processing"))
1457            .collect::<Vec<_>>();
1458        active.sort_by_key(|command| command.sequence);
1459        if let Some(head) = active.into_iter().next() {
1460            heads.push(head);
1461        }
1462    }
1463    heads.sort_by(|a, b| a.created_at.cmp(&b.created_at));
1464    Ok(heads)
1465}
1466
1467async fn claim_command(state: AppState, command_id: String) -> Result<SessionCommand, ApiError> {
1468    mutate_command(&state, &command_id, |command| {
1469        if command.status == "pending" {
1470            command.status = "processing".into();
1471            command.processing_started_at = Some(now());
1472        } else if command.status != "processing" {
1473            return Err(ApiError::conflict("Command is already complete."));
1474        }
1475        Ok(())
1476    })
1477}
1478
1479async fn complete_command(
1480    state: AppState,
1481    command_id: String,
1482    input: CommandOutcome,
1483) -> Result<SessionCommand, ApiError> {
1484    mutate_command(&state, &command_id, |command| {
1485        if command.status == "complete" {
1486            return Ok(());
1487        }
1488        if command.status != "processing" {
1489            return Err(ApiError::conflict("Command was not claimed."));
1490        }
1491        command.status = "complete".into();
1492        command.outcome = Some(input.outcome);
1493        command.completed_at = Some(now());
1494        Ok(())
1495    })
1496}
1497
1498async fn request_session_stop(
1499    state: AppState,
1500    id: String,
1501    idempotency_id: String,
1502    requested_scope: Option<String>,
1503) -> Result<(bool, SessionStopRequest), ApiError> {
1504    validate_idempotency(&idempotency_id)?;
1505    if requested_scope
1506        .as_deref()
1507        .is_some_and(|scope| !matches!(scope, "turn" | "session" | "self-time-run"))
1508    {
1509        return Err(ApiError::bad(
1510            "Stop scope must be turn, session, or self-time-run.",
1511        ));
1512    }
1513    let session_guard = session_mutation(&state, &id)?;
1514    let _guard = session_guard.lock().map_err(ApiError::internal)?;
1515    let mut journal = open_by_id(&state, &id)?;
1516    let lifecycle = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1517    if !matches!(
1518        lifecycle.phase.as_str(),
1519        "active" | "ingress_pending" | "ingress_in_progress"
1520    ) {
1521        return Err(ApiError::conflict("Session has no work in progress."));
1522    }
1523    let existing = stop_requests(&journal);
1524    if let Some(request) = existing
1525        .values()
1526        .find(|request| request.idempotency_id == idempotency_id)
1527    {
1528        return Ok((false, request.clone()));
1529    }
1530    if let Some(request) = existing
1531        .values()
1532        .find(|request| request.status == "pending")
1533    {
1534        return Ok((false, request.clone()));
1535    }
1536    let scope = match requested_scope {
1537        Some(scope) => scope,
1538        None => current_work_stop_scope(&lifecycle)?.into(),
1539    };
1540    let request = SessionStopRequest {
1541        id: Uuid::new_v4().to_string(),
1542        session_id: id,
1543        scope,
1544        status: "pending".into(),
1545        outcome: None,
1546        requested_at: now(),
1547        completed_at: None,
1548        idempotency_id,
1549    };
1550    append_stop_request(&mut journal, &request)?;
1551
1552    if request.scope == "turn" {
1553        let mut active = commands(&journal)
1554            .into_values()
1555            .filter(|command| {
1556                matches!(command.status.as_str(), "pending" | "processing")
1557                    && matches!(command.kind.as_str(), "message" | "retry")
1558            })
1559            .collect::<Vec<_>>();
1560        active.sort_by_key(|command| command.sequence);
1561        if let Some(mut command) = active.into_iter().next() {
1562            command.cancel_requested = true;
1563            append_command(&mut journal, &command)?;
1564        }
1565    }
1566    Ok((true, request))
1567}
1568
1569fn current_work_stop_scope(record: &SessionRecord) -> Result<&'static str, ApiError> {
1570    if record.phase != "active" {
1571        return Ok("session");
1572    }
1573    let kind = record
1574        .state
1575        .get("chatendMetadata")
1576        .cloned()
1577        .and_then(|value| serde_json::from_value::<chatend::SessionMetadata>(value).ok())
1578        .map(|metadata| metadata.kind);
1579    match kind {
1580        Some(
1581            chatend::SessionKind::Conversation
1582            | chatend::SessionKind::Telegram
1583            | chatend::SessionKind::TelegramGroup,
1584        ) => Ok("turn"),
1585        Some(chatend::SessionKind::SelfTime) => Ok("self-time-run"),
1586        Some(_) => Ok("session"),
1587        None => match record.state.get("sessionType").and_then(Value::as_str) {
1588            Some("conversation" | "telegram" | "telegram-group") => Ok("turn"),
1589            Some("free-time") => Ok("self-time-run"),
1590            Some(_) => Ok("session"),
1591            None => Err(ApiError::conflict(
1592                "Session does not identify the work that should stop.",
1593            )),
1594        },
1595    }
1596}
1597
1598fn listen_for_stop(state: &AppState, id: &str) -> Result<StopListener, ApiError> {
1599    let session_guard = session_mutation(state, id)?;
1600    let _guard = session_guard.lock().map_err(ApiError::internal)?;
1601    let journal = open_by_id(state, id)?;
1602    let signal = {
1603        let mut listeners = state.stop_listeners.lock().map_err(ApiError::internal)?;
1604        listeners.retain(|_, listener| listener.strong_count() > 0);
1605        if let Some(signal) = listeners.get(id).and_then(Weak::upgrade) {
1606            signal
1607        } else {
1608            let signal = Arc::new(StopSignal {
1609                requested: AtomicBool::new(false),
1610                notification: Notify::new(),
1611            });
1612            listeners.insert(id.to_owned(), Arc::downgrade(&signal));
1613            signal
1614        }
1615    };
1616    if stop_requests(&journal)
1617        .values()
1618        .any(|request| request.status == "pending")
1619    {
1620        signal_stop(&signal);
1621    }
1622    Ok(StopListener { signal })
1623}
1624
1625fn signal_stop_listener(state: &AppState, id: &str) -> Result<(), ApiError> {
1626    let signal = state
1627        .stop_listeners
1628        .lock()
1629        .map_err(ApiError::internal)?
1630        .get(id)
1631        .and_then(Weak::upgrade);
1632    if let Some(signal) = signal {
1633        signal_stop(&signal);
1634    }
1635    Ok(())
1636}
1637
1638fn signal_stop(signal: &StopSignal) {
1639    signal.requested.store(true, Ordering::Release);
1640    signal.notification.notify_waiters();
1641}
1642
1643async fn list_stop_heads(state: AppState) -> Result<Vec<SessionStopRequest>, ApiError> {
1644    let mut heads = Vec::new();
1645    for journal in open_listed_journals(journal_paths(&state.config.directory)?)? {
1646        if let Some(request) = stop_requests(&journal)
1647            .into_values()
1648            .find(|request| request.status == "pending")
1649        {
1650            heads.push(request);
1651        }
1652    }
1653    heads.sort_by(|left, right| left.requested_at.cmp(&right.requested_at));
1654    Ok(heads)
1655}
1656
1657async fn complete_stop_request(
1658    state: AppState,
1659    request_id: String,
1660    input: StopOutcome,
1661) -> Result<SessionStopRequest, ApiError> {
1662    mutate_stop_request(&state, &request_id, |request| {
1663        if request.status == "complete" {
1664            return Ok(());
1665        }
1666        request.status = "complete".into();
1667        request.outcome = Some(input.outcome);
1668        request.completed_at = Some(now());
1669        Ok(())
1670    })
1671}
1672
1673fn mutate_command(
1674    state: &AppState,
1675    command_id: &str,
1676    mutation: impl FnOnce(&mut SessionCommand) -> Result<(), ApiError>,
1677) -> Result<SessionCommand, ApiError> {
1678    let mut target = None;
1679    for path in journal_paths(&state.config.directory)? {
1680        let journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
1681        if let Some(command) = commands(&journal).remove(command_id) {
1682            target = Some((path, command.conversation_id));
1683            break;
1684        }
1685    }
1686    let (path, conversation_id) = target.ok_or_else(ApiError::not_found)?;
1687    let session_guard = session_mutation(state, &conversation_id)?;
1688    let _guard = session_guard.lock().map_err(ApiError::internal)?;
1689    let mut journal = SessionJournal::open(path).map_err(ApiError::internal)?;
1690    let mut command = commands(&journal)
1691        .remove(command_id)
1692        .ok_or_else(ApiError::not_found)?;
1693    mutation(&mut command)?;
1694    append_command(&mut journal, &command)?;
1695    Ok(command)
1696}
1697
1698fn mutate_stop_request(
1699    state: &AppState,
1700    request_id: &str,
1701    mutation: impl FnOnce(&mut SessionStopRequest) -> Result<(), ApiError>,
1702) -> Result<SessionStopRequest, ApiError> {
1703    let mut target = None;
1704    for path in journal_paths(&state.config.directory)? {
1705        let journal = SessionJournal::open(&path).map_err(ApiError::internal)?;
1706        if let Some(request) = stop_requests(&journal).remove(request_id) {
1707            target = Some((path, request.session_id));
1708            break;
1709        }
1710    }
1711    let (path, session_id) = target.ok_or_else(ApiError::not_found)?;
1712    let session_guard = session_mutation(state, &session_id)?;
1713    let _guard = session_guard.lock().map_err(ApiError::internal)?;
1714    let mut journal = SessionJournal::open(path).map_err(ApiError::internal)?;
1715    let mut request = stop_requests(&journal)
1716        .remove(request_id)
1717        .ok_or_else(ApiError::not_found)?;
1718    mutation(&mut request)?;
1719    append_stop_request(&mut journal, &request)?;
1720    Ok(request)
1721}
1722
1723async fn checkpoint(
1724    state: AppState,
1725    id: &str,
1726    expected_version: i64,
1727    new_state: Value,
1728    user_activity: bool,
1729) -> Result<SessionRecord, ApiError> {
1730    let session_guard = session_mutation(&state, id)?;
1731    let _guard = session_guard.lock().map_err(ApiError::internal)?;
1732    let mut journal = open_by_id(&state, id)?;
1733    let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1734    require_version(&record, expected_version)?;
1735    record.state = control_state(&new_state);
1736    record.version += 1;
1737    record.updated_at = now();
1738    if user_activity {
1739        record.last_user_message_at = Some(record.updated_at.clone());
1740    }
1741    append_lifecycle(&mut journal, &record)?;
1742    Ok(materialize(
1743        record,
1744        &journal,
1745        state.config.provider_cost_compatibility,
1746    ))
1747}
1748
1749async fn transition_with_checkpoint(
1750    state: AppState,
1751    id: &str,
1752    input: Checkpoint,
1753    phase: &str,
1754) -> Result<SessionRecord, ApiError> {
1755    let session_guard = session_mutation(&state, id)?;
1756    let _guard = session_guard.lock().map_err(ApiError::internal)?;
1757    let mut journal = open_by_id(&state, id)?;
1758    let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1759    require_version(&record, input.expected_version)?;
1760    record.state = control_state(&input.state);
1761    record.phase = phase.into();
1762    if phase == "ingress_pending" {
1763        record.ingress_next_attempt_at = None;
1764    }
1765    record.version += 1;
1766    record.updated_at = now();
1767    append_lifecycle(&mut journal, &record)?;
1768    Ok(materialize(
1769        record,
1770        &journal,
1771        state.config.provider_cost_compatibility,
1772    ))
1773}
1774
1775async fn transition(
1776    state: AppState,
1777    id: &str,
1778    expected_version: i64,
1779    phase: &str,
1780    provenance_id: Option<String>,
1781) -> Result<SessionRecord, ApiError> {
1782    let session_guard = session_mutation(&state, id)?;
1783    let _guard = session_guard.lock().map_err(ApiError::internal)?;
1784    let mut journal = open_by_id(&state, id)?;
1785    let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1786    require_version(&record, expected_version)?;
1787    record.phase = phase.into();
1788    record.provenance_id = provenance_id;
1789    if phase == "ingress_in_progress" {
1790        record.ingress_next_attempt_at = None;
1791    }
1792    record.version += 1;
1793    record.updated_at = now();
1794    append_lifecycle(&mut journal, &record)?;
1795    Ok(materialize(
1796        record,
1797        &journal,
1798        state.config.provider_cost_compatibility,
1799    ))
1800}
1801
1802async fn record_ingress_failure(
1803    state: AppState,
1804    id: &str,
1805    input: IngressFailure,
1806) -> Result<SessionRecord, ApiError> {
1807    let session_guard = session_mutation(&state, id)?;
1808    let _guard = session_guard.lock().map_err(ApiError::internal)?;
1809    let mut journal = open_by_id(&state, id)?;
1810    let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1811    require_version(&record, input.expected_version)?;
1812    if !matches!(
1813        record.phase.as_str(),
1814        "ingress_pending" | "ingress_in_progress"
1815    ) {
1816        return Err(ApiError::conflict(
1817            "Session History ingress is not in an active attempt.",
1818        ));
1819    }
1820    let attempt = record.ingress_failure_count.saturating_add(1);
1821    let terminal =
1822        input.code.as_deref() == Some("input_too_large") || attempt >= INGRESS_FAILURE_LIMIT;
1823    let mut failures = record
1824        .ingress_failures
1825        .as_array()
1826        .cloned()
1827        .unwrap_or_default();
1828    failures.push(json!({
1829        "attempt":attempt,
1830        "at":now(),
1831        "stage":input.stage,
1832        "code":input.code,
1833        "message":input.message,
1834        "roundsUsed":input.rounds_used,
1835        "contextTokens":input.context_tokens,
1836        "contextWindowTokens":input.context_window_tokens,
1837    }));
1838    if failures.len() > RETAINED_INGRESS_FAILURES {
1839        failures.drain(..failures.len() - RETAINED_INGRESS_FAILURES);
1840    }
1841    record.ingress_failures = Value::Array(failures);
1842    record.ingress_failure_count = attempt;
1843    record.phase = if terminal {
1844        "ingress_failed".into()
1845    } else {
1846        "ingress_pending".into()
1847    };
1848    let updated_at = now();
1849    record.ingress_next_attempt_at = (!terminal)
1850        .then(|| (Utc::now() + Duration::seconds(INGRESS_RETRY_DELAY_SECONDS)).to_rfc3339());
1851    record.version += 1;
1852    record.updated_at = updated_at;
1853    append_lifecycle(&mut journal, &record)?;
1854    if terminal {
1855        tracing::error!(
1856            session_id = id,
1857            attempt,
1858            stage = %input.stage,
1859            code = input.code.as_deref().unwrap_or("ingress_error"),
1860            terminal_reason = if input.code.as_deref() == Some("input_too_large") {
1861                "non_retryable"
1862            } else {
1863                "retry_limit"
1864            },
1865            "Session History ingress stopped after a terminal failure"
1866        );
1867    }
1868    Ok(materialize(
1869        record,
1870        &journal,
1871        state.config.provider_cost_compatibility,
1872    ))
1873}
1874
1875async fn retry_ingress(
1876    state: AppState,
1877    id: String,
1878    input: RetryIngress,
1879) -> Result<SessionRecord, ApiError> {
1880    let session_guard = session_mutation(&state, &id)?;
1881    let _guard = session_guard.lock().map_err(ApiError::internal)?;
1882    let mut journal = open_by_id(&state, &id)?;
1883    let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1884    require_version(&record, input.expected_version)?;
1885    if record.phase != "ingress_failed" {
1886        return Err(ApiError::conflict(
1887            "Session History ingress is not in the failed state.",
1888        ));
1889    }
1890    record.state = control_state(&input.state);
1891    record.phase = "ingress_pending".into();
1892    record.ingress_failure_count = 0;
1893    record.ingress_next_attempt_at = None;
1894    record.version += 1;
1895    record.updated_at = now();
1896    append_lifecycle(&mut journal, &record)?;
1897    Ok(materialize(
1898        record,
1899        &journal,
1900        state.config.provider_cost_compatibility,
1901    ))
1902}
1903
1904async fn release_ingress_repairs(state: AppState) -> Result<Vec<String>, ApiError> {
1905    let mut released = Vec::new();
1906    for path in journal_paths(&state.config.directory)? {
1907        let Some(id) = path
1908            .file_stem()
1909            .and_then(|value| value.to_str())
1910            .map(str::to_owned)
1911        else {
1912            continue;
1913        };
1914        let session_guard = session_mutation(&state, &id)?;
1915        let _guard = session_guard.lock().map_err(ApiError::internal)?;
1916        let mut journal = open_by_id(&state, &id)?;
1917        let Some(mut record) = latest_lifecycle(&journal) else {
1918            continue;
1919        };
1920        if record.phase != "ingress_in_progress" {
1921            continue;
1922        }
1923        record.phase = "ingress_pending".into();
1924        record.ingress_next_attempt_at = None;
1925        if record.ingress_failure_count > INGRESS_FAILURE_LIMIT {
1926            record.ingress_failure_count = 0;
1927        }
1928        if let Some(failures) = record.ingress_failures.as_array_mut()
1929            && failures.len() > RETAINED_INGRESS_FAILURES
1930        {
1931            failures.drain(..failures.len() - RETAINED_INGRESS_FAILURES);
1932        }
1933        record.version += 1;
1934        record.updated_at = now();
1935        append_lifecycle(&mut journal, &record)?;
1936        released.push(id);
1937    }
1938    Ok(released)
1939}
1940
1941async fn complete_session(
1942    state: AppState,
1943    id: &str,
1944    expected_version: i64,
1945    new_state: Value,
1946) -> Result<SessionRecord, ApiError> {
1947    let session_guard = session_mutation(&state, id)?;
1948    let _guard = session_guard.lock().map_err(ApiError::internal)?;
1949    let journal = open_by_id(&state, id)?;
1950    let mut record = latest_lifecycle(&journal).ok_or_else(ApiError::not_found)?;
1951    require_version(&record, expected_version)?;
1952    let completion_state = new_state
1953        .get("historyIngress")
1954        .filter(|state| state.get("sessionObjectId").is_some_and(Value::is_string))
1955        .cloned()
1956        .unwrap_or_else(|| new_state.clone());
1957    record.state = control_state(&new_state);
1958    let object_id = completion_state
1959        .get("sessionObjectId")
1960        .and_then(Value::as_str)
1961        .ok_or_else(|| {
1962            ApiError::conflict("completed session has no permanent Kweb session object")
1963        })?
1964        .to_owned();
1965    let _catalog_guard = state.catalog_mutation.lock().map_err(ApiError::internal)?;
1966    let mut receipt = completion_state
1967        .get("commitReceipt")
1968        .filter(|receipt| !receipt.is_null())
1969        .cloned()
1970        .map(serde_json::from_value::<CompletionReceipt>)
1971        .transpose()
1972        .map_err(|error| ApiError::conflict(format!("invalid session commit receipt: {error}")))?
1973        .unwrap_or(CompletionReceipt {
1974            transaction_id: None,
1975            session_object_id: object_id.clone(),
1976            session_id: None,
1977            session_type: None,
1978            created_at: None,
1979            committed_at: None,
1980            ingress_source: None,
1981            node_ids: BTreeMap::new(),
1982            object_ids: BTreeMap::new(),
1983        });
1984    if receipt.session_object_id != object_id {
1985        return Err(ApiError::conflict(
1986            "session commit receipt names a different archive object",
1987        ));
1988    }
1989    let completed_at = now();
1990    receipt.session_id = Some(record.id.clone());
1991    receipt.session_type = record
1992        .state
1993        .get("sessionType")
1994        .and_then(Value::as_str)
1995        .map(str::to_owned);
1996    receipt.created_at = Some(record.started_at.clone());
1997    receipt.committed_at = Some(completed_at.clone());
1998    receipt.ingress_source = record.state.get("ingressSource").cloned();
1999    append_completion_receipt(&state.config.completed_list, &receipt)
2000        .map_err(ApiError::internal)?;
2001    record.phase = "complete".into();
2002    record.version += 1;
2003    record.updated_at = completed_at;
2004    record.ended_at = Some(record.updated_at.clone());
2005    record.state["sessionObjectId"] = json!(object_id);
2006    record.state["commitReceipt"] = completion_state
2007        .get("commitReceipt")
2008        .cloned()
2009        .unwrap_or(Value::Null);
2010    let output = materialize(record, &journal, state.config.provider_cost_compatibility);
2011    let control_path = control_path(&state.config.directory, id);
2012    journal.log.delete_committed().map_err(ApiError::internal)?;
2013    if control_path.exists() {
2014        std::fs::remove_file(&control_path)
2015            .with_context(|| format!("removing {}", control_path.display()))
2016            .map_err(ApiError::internal)?;
2017        sync_directory(&state.config.directory).map_err(ApiError::internal)?;
2018    }
2019    Ok(output)
2020}
2021
2022fn fetch_active(state: &AppState, id: &str) -> Result<SessionRecord, ApiError> {
2023    let journal = open_by_id(state, id)?;
2024    latest_lifecycle(&journal).ok_or_else(ApiError::not_found)
2025}
2026
2027fn session_mutation(state: &AppState, id: &str) -> Result<Arc<Mutex<()>>, ApiError> {
2028    let mut sessions = state.session_mutations.lock().map_err(ApiError::internal)?;
2029    sessions.retain(|_, lock| lock.strong_count() > 0);
2030    if let Some(lock) = sessions.get(id).and_then(Weak::upgrade) {
2031        return Ok(lock);
2032    }
2033    let lock = Arc::new(Mutex::new(()));
2034    sessions.insert(id.to_owned(), Arc::downgrade(&lock));
2035    Ok(lock)
2036}
2037
2038fn open_by_id(state: &AppState, id: &str) -> Result<SessionJournal, ApiError> {
2039    validate_session_id(id)?;
2040    let path = state.config.directory.join(format!("{id}.session-log"));
2041    SessionJournal::open(&path).map_err(|error| {
2042        if !path.exists() {
2043            ApiError::not_found()
2044        } else {
2045            ApiError::internal(error)
2046        }
2047    })
2048}
2049
2050fn latest_lifecycle(journal: &SessionJournal) -> Option<SessionRecord> {
2051    journal
2052        .records()
2053        .iter()
2054        .rev()
2055        .find(|record| record.kind == LIFECYCLE_SIDEBAND)
2056        .and_then(|record| serde_json::from_value(record.value.clone()).ok())
2057}
2058
2059fn append_lifecycle(journal: &mut SessionJournal, record: &SessionRecord) -> Result<(), ApiError> {
2060    journal
2061        .append_control(
2062            LIFECYCLE_SIDEBAND,
2063            now(),
2064            serde_json::to_value(record).map_err(ApiError::internal)?,
2065        )
2066        .map_err(ApiError::internal)
2067}
2068
2069fn commands(journal: &SessionJournal) -> BTreeMap<String, SessionCommand> {
2070    let mut commands = BTreeMap::new();
2071    for record in journal
2072        .records()
2073        .iter()
2074        .filter(|record| record.kind == COMMAND_SIDEBAND)
2075    {
2076        if let Ok(command) = serde_json::from_value::<SessionCommand>(record.value.clone()) {
2077            commands.insert(command.id.clone(), command);
2078        }
2079    }
2080    commands
2081}
2082
2083fn append_command(journal: &mut SessionJournal, command: &SessionCommand) -> Result<(), ApiError> {
2084    journal
2085        .append_control(
2086            COMMAND_SIDEBAND,
2087            now(),
2088            serde_json::to_value(command).map_err(ApiError::internal)?,
2089        )
2090        .map_err(ApiError::internal)
2091}
2092
2093fn stop_requests(journal: &SessionJournal) -> BTreeMap<String, SessionStopRequest> {
2094    let mut requests = BTreeMap::new();
2095    for record in journal
2096        .records()
2097        .iter()
2098        .filter(|record| record.kind == STOP_SIDEBAND)
2099    {
2100        if let Ok(request) = serde_json::from_value::<SessionStopRequest>(record.value.clone()) {
2101            requests.insert(request.id.clone(), request);
2102        }
2103    }
2104    requests
2105}
2106
2107fn append_stop_request(
2108    journal: &mut SessionJournal,
2109    request: &SessionStopRequest,
2110) -> Result<(), ApiError> {
2111    journal
2112        .append_control(
2113            STOP_SIDEBAND,
2114            now(),
2115            serde_json::to_value(request).map_err(ApiError::internal)?,
2116        )
2117        .map_err(ApiError::internal)
2118}
2119
2120fn materialize(
2121    mut record: SessionRecord,
2122    journal: &SessionJournal,
2123    provider_cost_compatibility: Option<ProviderCostCompatibility>,
2124) -> SessionRecord {
2125    let log = journal.list();
2126    record.state["sessionId"] = json!(log.header.session_id);
2127    if !record.state.get("transcript").is_some_and(Value::is_array) {
2128        record.state["transcript"] = Value::Array(
2129            log.events
2130                .iter()
2131                .enumerate()
2132                .filter_map(|(position, event)| transcript_entry(position, event))
2133                .collect(),
2134        );
2135    }
2136    if !record.state.get("events").is_some_and(Value::is_array) {
2137        record.state["events"] = serde_json::to_value(&log.events).unwrap_or(Value::Null);
2138    }
2139    let context_state = record
2140        .state
2141        .get("historyIngress")
2142        .filter(|state| state.get("chatendMetadata").is_some())
2143        .unwrap_or(&record.state);
2144    let default_provider_model = provider_cost_compatibility
2145        .and_then(|compatibility| (compatibility.session_model)(context_state))
2146        .or_else(|| {
2147            provider_cost_compatibility
2148                .and_then(|compatibility| (compatibility.session_model)(&record.state))
2149        });
2150    let exact_chatend = context_state
2151        .get("chatendMetadata")
2152        .cloned()
2153        .and_then(|value| serde_json::from_value::<chatend::SessionMetadata>(value).ok())
2154        .and_then(|metadata| match provider_cost_compatibility {
2155            Some(compatibility) => chatend::Chatend::replay_with_provider_costs(
2156                metadata,
2157                &log,
2158                default_provider_model.as_deref(),
2159                Some(compatibility.estimator),
2160            )
2161            .ok(),
2162            None => chatend::Chatend::replay(metadata, &log).ok(),
2163        });
2164    if let Some(chatend) = exact_chatend {
2165        let boxes = serde_json::to_value(&chatend.boxes).unwrap_or(Value::Null);
2166        let projection = chatend.projection();
2167        let chatend_text = Value::String(projection.render());
2168        let context = serde_json::to_value(projection).unwrap_or(Value::Null);
2169        record.state["boxes"] = boxes.clone();
2170        record.state["context"] = context.clone();
2171        record.state["chatendText"] = chatend_text.clone();
2172        if let Some(ingress) = record
2173            .state
2174            .get_mut("historyIngress")
2175            .and_then(Value::as_object_mut)
2176        {
2177            ingress.insert("boxes".into(), boxes);
2178            ingress.insert("context".into(), context);
2179            ingress.insert("chatendText".into(), chatend_text);
2180        }
2181    }
2182    record
2183}
2184
2185fn summary_state(control: &Value, journal: &SessionJournal) -> Value {
2186    let log = journal.list();
2187    let first_user = log
2188        .events
2189        .iter()
2190        .find(|event| event.role == Role::UserMessage)
2191        .map(display_text)
2192        .map(|text| text.chars().take(512).collect::<String>());
2193    json!({
2194        "sessionType":control.get("sessionType"),
2195        "channel":control.get("channel"),
2196        "freeTime":control.get("freeTime"),
2197        "orchestration":control.get("orchestration"),
2198        "ingressSource":control.get("ingressSource"),
2199        "firstUserMessage":first_user,
2200        "boxCount":log.events.len(),
2201        "eventCount":log.events.len(),
2202        "pendingTurn":control.get("pendingTurn").cloned().unwrap_or(Value::Bool(false)),
2203    })
2204}
2205
2206fn persisted_context_kind(event: &kcode_session_log::SessionEvent) -> Option<Value> {
2207    serde_json::from_str::<Value>(&event.text)
2208        .ok()?
2209        .get("kind")
2210        .cloned()
2211}
2212
2213fn display_text(event: &kcode_session_log::SessionEvent) -> String {
2214    persisted_context_kind(event)
2215        .and_then(|kind| {
2216            (kind.get("type").and_then(Value::as_str) == Some("box_created"))
2217                .then(|| {
2218                    kind.get("content")
2219                        .and_then(|content| content.get("text"))
2220                        .and_then(Value::as_str)
2221                        .map(str::to_owned)
2222                })
2223                .flatten()
2224        })
2225        .unwrap_or_else(|| event.text.clone())
2226}
2227
2228fn transcript_entry(position: usize, event: &kcode_session_log::SessionEvent) -> Option<Value> {
2229    let kind = persisted_context_kind(event);
2230    let box_content = kind
2231        .as_ref()
2232        .filter(|kind| kind.get("type").and_then(Value::as_str) == Some("box_created"))
2233        .and_then(|kind| kind.get("content"));
2234    let metadata = box_content
2235        .and_then(|content| content.get("metadata"))
2236        .filter(|value| value.is_object());
2237    let role = match event.role {
2238        Role::UserMessage => "user",
2239        Role::KennedyMessage => "kennedy",
2240        Role::SystemError => "system",
2241        Role::SystemMessage => (box_content?
2242            .get("metadata")
2243            .and_then(|metadata| metadata.get("transcriptRole"))
2244            .and_then(Value::as_str)
2245            == Some("system"))
2246        .then_some("system")?,
2247        _ => return None,
2248    };
2249    let mut item = json!({
2250        "role":role,
2251        "content":display_text(event),
2252        "boxId":position + 1,
2253    });
2254    if let Some(objects) = box_content
2255        .and_then(|content| content.get("objects"))
2256        .filter(|value| value.is_array())
2257    {
2258        item["objects"] = objects.clone();
2259    }
2260    if let Some(metadata) = metadata {
2261        for key in ["inputKind", "externalEventId"] {
2262            if let Some(value) = metadata.get(key) {
2263                item[key] = value.clone();
2264            }
2265        }
2266        if let Some(attachments) = metadata.get("attachments").filter(|value| value.is_array()) {
2267            item["attachments"] = attachments.clone();
2268        } else if let Some(media) = metadata.get("media").filter(|value| value.is_object()) {
2269            item["attachments"] = json!([media]);
2270        }
2271    }
2272    Some(item)
2273}
2274
2275fn control_state(value: &Value) -> Value {
2276    const KEYS: &[&str] = &[
2277        "format",
2278        "version",
2279        "stateVersion",
2280        "sessionId",
2281        "sessionType",
2282        "sourceSessionType",
2283        "channel",
2284        "freeTime",
2285        "selfTimeIntent",
2286        "orchestration",
2287        "provenanceId",
2288        "rustLibSessionId",
2289        "rootNodeIds",
2290        "referenceRootNodeIds",
2291        "startedAt",
2292        "pendingTurn",
2293        "pendingExternalEventId",
2294        "roundsUsed",
2295        "completed",
2296        "sessionObjectId",
2297        "commitReceipt",
2298        "commitAuthor",
2299        "providerModel",
2300        "kwebPlan",
2301        "startIdempotencyId",
2302        "ingressSource",
2303        "chatendMetadata",
2304        "sessionStatus",
2305        "historyIngress",
2306    ];
2307    let mut output = Map::new();
2308    for key in KEYS {
2309        if let Some(item) = value.get(*key) {
2310            if *key == "commitReceipt" && item.is_null() {
2311                continue;
2312            }
2313            let item = if *key == "historyIngress" {
2314                control_state(item)
2315            } else {
2316                item.clone()
2317            };
2318            output.insert((*key).into(), item);
2319        }
2320    }
2321    Value::Object(output)
2322}
2323
2324fn compact_control_journals(directory: &FilePath) -> anyhow::Result<()> {
2325    let mut paths = std::fs::read_dir(directory)?
2326        .filter_map(Result::ok)
2327        .map(|entry| entry.path())
2328        .filter(|path| path.extension().and_then(|value| value.to_str()) == Some(CONTROL_EXTENSION))
2329        .collect::<Vec<_>>();
2330    paths.sort();
2331    for path in paths {
2332        compact_control_journal(&path)?;
2333    }
2334    Ok(())
2335}
2336
2337fn compact_control_journal(path: &FilePath) -> anyhow::Result<()> {
2338    let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
2339    let original_bytes = file.metadata()?.len();
2340    if original_bytes >= 16 * 1024 * 1024 {
2341        tracing::info!(
2342            path = %path.display(),
2343            original_bytes,
2344            "Compacting legacy Session History control journal"
2345        );
2346    }
2347    let mut reader = BufReader::new(file);
2348    let mut latest_lifecycle = None;
2349    let mut latest_commands = BTreeMap::<String, (u64, ControlRecord)>::new();
2350    let mut latest_stop_requests = BTreeMap::<String, (u64, ControlRecord)>::new();
2351    let mut retained_other = Vec::new();
2352    let mut sequence = 0_u64;
2353    let mut needs_rewrite = false;
2354
2355    loop {
2356        let mut line = Vec::new();
2357        let bytes = reader.read_until(b'\n', &mut line)?;
2358        if bytes == 0 {
2359            break;
2360        }
2361        if line.last() != Some(&b'\n') {
2362            needs_rewrite = true;
2363            break;
2364        }
2365        line.pop();
2366        if line.last() == Some(&b'\r') {
2367            line.pop();
2368        }
2369        let mut record = parse_control_record(&line)?;
2370        match record.kind.as_str() {
2371            LIFECYCLE_SIDEBAND => {
2372                if let Some(state) = record.value.get_mut("state") {
2373                    let projected = control_state(state);
2374                    if *state != projected {
2375                        *state = projected;
2376                        needs_rewrite = true;
2377                    }
2378                }
2379                if latest_lifecycle.replace((sequence, record)).is_some() {
2380                    needs_rewrite = true;
2381                }
2382            }
2383            COMMAND_SIDEBAND => {
2384                let id = record
2385                    .value
2386                    .get("id")
2387                    .and_then(Value::as_str)
2388                    .context("session command record has no ID")?
2389                    .to_owned();
2390                if latest_commands.insert(id, (sequence, record)).is_some() {
2391                    needs_rewrite = true;
2392                }
2393            }
2394            STOP_SIDEBAND => {
2395                let id = record
2396                    .value
2397                    .get("id")
2398                    .and_then(Value::as_str)
2399                    .context("session stop record has no ID")?
2400                    .to_owned();
2401                if latest_stop_requests
2402                    .insert(id, (sequence, record))
2403                    .is_some()
2404                {
2405                    needs_rewrite = true;
2406                }
2407            }
2408            _ => retained_other.push((sequence, record)),
2409        }
2410        sequence += 1;
2411    }
2412    drop(reader);
2413
2414    if !needs_rewrite {
2415        return Ok(());
2416    }
2417
2418    let mut retained = retained_other;
2419    retained.extend(latest_lifecycle);
2420    retained.extend(latest_commands.into_values());
2421    retained.extend(latest_stop_requests.into_values());
2422    retained.sort_by_key(|(sequence, _)| *sequence);
2423    rewrite_control_journal(path, retained.into_iter().map(|(_, record)| record))?;
2424    tracing::info!(
2425        path = %path.display(),
2426        original_bytes,
2427        compacted_bytes = std::fs::metadata(path)?.len(),
2428        "Compacted Session History control journal"
2429    );
2430    Ok(())
2431}
2432
2433fn parse_control_record(line: &[u8]) -> anyhow::Result<ControlRecord> {
2434    let separator = line
2435        .iter()
2436        .position(|byte| *byte == b' ')
2437        .context("session-control record has no checksum separator")?;
2438    let expected = std::str::from_utf8(&line[..separator])?;
2439    let payload = &line[separator + 1..];
2440    ensure!(
2441        hex_sha256(payload) == expected,
2442        "session-control record checksum mismatch"
2443    );
2444    serde_json::from_slice(payload).context("decoding session-control record")
2445}
2446
2447fn rewrite_control_journal(
2448    path: &FilePath,
2449    records: impl IntoIterator<Item = ControlRecord>,
2450) -> anyhow::Result<()> {
2451    let parent = path.parent().unwrap_or_else(|| FilePath::new("."));
2452    let file_name = path
2453        .file_name()
2454        .and_then(|value| value.to_str())
2455        .context("session-control filename is not valid UTF-8")?;
2456    let temporary = parent.join(format!(".{file_name}.compact-{}.tmp", Uuid::new_v4()));
2457    let result = (|| -> anyhow::Result<()> {
2458        let mut file = OpenOptions::new()
2459            .create_new(true)
2460            .write(true)
2461            .open(&temporary)
2462            .with_context(|| format!("creating {}", temporary.display()))?;
2463        file.set_permissions(std::fs::metadata(path)?.permissions())?;
2464        for record in records {
2465            write_control_record(&mut file, &record)?;
2466        }
2467        file.sync_all()?;
2468        std::fs::rename(&temporary, path)
2469            .with_context(|| format!("installing compacted {}", path.display()))?;
2470        sync_directory(parent)?;
2471        Ok(())
2472    })();
2473    if result.is_err() && temporary.exists() {
2474        let _ = std::fs::remove_file(&temporary);
2475    }
2476    result
2477}
2478
2479fn write_control_record(file: &mut File, record: &ControlRecord) -> anyhow::Result<()> {
2480    let payload = serde_json::to_vec(record)?;
2481    writeln!(
2482        file,
2483        "{} {}",
2484        hex_sha256(&payload),
2485        String::from_utf8(payload)?
2486    )?;
2487    Ok(())
2488}
2489
2490fn journal_paths(directory: &FilePath) -> Result<Vec<PathBuf>, ApiError> {
2491    let mut paths = std::fs::read_dir(directory)
2492        .map_err(ApiError::internal)?
2493        .filter_map(Result::ok)
2494        .map(|entry| entry.path())
2495        .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("session-log"))
2496        .collect::<Vec<_>>();
2497    paths.sort();
2498    Ok(paths)
2499}
2500
2501fn open_listed_journals(paths: Vec<PathBuf>) -> Result<Vec<SessionJournal>, ApiError> {
2502    let mut journals = Vec::with_capacity(paths.len());
2503    for path in paths {
2504        match SessionJournal::open_existing(&path) {
2505            Ok(Some(journal)) => journals.push(journal),
2506            Ok(None) => {}
2507            Err(_) if !path.exists() => {}
2508            Err(error) => return Err(ApiError::internal(error)),
2509        }
2510    }
2511    Ok(journals)
2512}
2513
2514fn read_completed_ids(path: &FilePath) -> anyhow::Result<Vec<String>> {
2515    Ok(read_completion_receipts(path)?
2516        .into_iter()
2517        .map(|receipt| receipt.session_object_id)
2518        .collect())
2519}
2520
2521fn read_completion_receipts(path: &FilePath) -> anyhow::Result<Vec<CompletionReceipt>> {
2522    let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
2523    let mut receipts = Vec::new();
2524    let mut seen = HashSet::new();
2525    for line in BufReader::new(file).lines() {
2526        let line = line?;
2527        let line = line.trim();
2528        if line.is_empty() {
2529            continue;
2530        }
2531        let receipt = if line.starts_with('{') {
2532            serde_json::from_str::<CompletionReceipt>(line)
2533                .context("decoding Session History completion receipt")?
2534        } else {
2535            CompletionReceipt {
2536                transaction_id: None,
2537                session_object_id: line.to_owned(),
2538                session_id: None,
2539                session_type: None,
2540                created_at: None,
2541                committed_at: None,
2542                ingress_source: None,
2543                node_ids: BTreeMap::new(),
2544                object_ids: BTreeMap::new(),
2545            }
2546        };
2547        if seen.insert(receipt.session_object_id.clone()) {
2548            receipts.push(receipt);
2549        }
2550    }
2551    Ok(receipts)
2552}
2553
2554#[cfg(test)]
2555fn append_completed_id(path: &FilePath, id: &str) -> anyhow::Result<()> {
2556    append_completion_receipt(
2557        path,
2558        &CompletionReceipt {
2559            transaction_id: None,
2560            session_object_id: id.into(),
2561            session_id: None,
2562            session_type: None,
2563            created_at: None,
2564            committed_at: None,
2565            ingress_source: None,
2566            node_ids: BTreeMap::new(),
2567            object_ids: BTreeMap::new(),
2568        },
2569    )
2570}
2571
2572fn append_completion_receipt(path: &FilePath, receipt: &CompletionReceipt) -> anyhow::Result<()> {
2573    if read_completed_ids(path)?
2574        .iter()
2575        .any(|existing| existing == &receipt.session_object_id)
2576    {
2577        return Ok(());
2578    }
2579    let mut file = OpenOptions::new().append(true).open(path)?;
2580    writeln!(file, "{}", serde_json::to_string(receipt)?)?;
2581    file.flush()?;
2582    file.sync_data()?;
2583    Ok(())
2584}
2585
2586fn create_private_directory(path: &FilePath) -> anyhow::Result<()> {
2587    if path.is_dir() {
2588        return Ok(());
2589    }
2590    let mut builder = std::fs::DirBuilder::new();
2591    builder.recursive(true);
2592    #[cfg(unix)]
2593    {
2594        use std::os::unix::fs::DirBuilderExt as _;
2595        builder.mode(0o700);
2596    }
2597    builder
2598        .create(path)
2599        .with_context(|| format!("creating {}", path.display()))?;
2600    let parent = path
2601        .parent()
2602        .filter(|parent| !parent.as_os_str().is_empty())
2603        .unwrap_or_else(|| FilePath::new("."));
2604    sync_directory(parent)
2605}
2606
2607fn sync_directory(path: &FilePath) -> anyhow::Result<()> {
2608    File::open(path)
2609        .with_context(|| format!("opening directory {} for sync", path.display()))?
2610        .sync_all()
2611        .with_context(|| format!("syncing directory {}", path.display()))
2612}
2613
2614fn validate_started_at(value: &str) -> Result<(), ApiError> {
2615    DateTime::parse_from_rfc3339(value)
2616        .map(|_| ())
2617        .map_err(|_| ApiError::bad("started_at must be an RFC 3339 timestamp"))
2618}
2619
2620fn validate_idempotency(value: &str) -> Result<(), ApiError> {
2621    if value.is_empty() || value.len() > 255 {
2622        return Err(ApiError::bad(
2623            "idempotency_id must contain between 1 and 255 bytes",
2624        ));
2625    }
2626    Ok(())
2627}
2628
2629fn validate_session_id(value: &str) -> Result<(), ApiError> {
2630    Uuid::parse_str(value)
2631        .map(|_| ())
2632        .map_err(|_| ApiError::bad("invalid session ID"))
2633}
2634
2635fn require_version(record: &SessionRecord, expected: i64) -> Result<(), ApiError> {
2636    if record.version != expected {
2637        return Err(ApiError::conflict(format!(
2638            "Expected session version {expected}, found {}.",
2639            record.version
2640        )));
2641    }
2642    Ok(())
2643}
2644
2645fn now() -> String {
2646    Utc::now().to_rfc3339()
2647}
2648
2649#[cfg(test)]
2650mod tests {
2651    use std::time::{SystemTime, UNIX_EPOCH};
2652
2653    use super::*;
2654
2655    fn root(label: &str) -> PathBuf {
2656        std::env::temp_dir().join(format!(
2657            "kennedy-session-history-{label}-{}-{}",
2658            std::process::id(),
2659            SystemTime::now()
2660                .duration_since(UNIX_EPOCH)
2661                .unwrap()
2662                .as_nanos()
2663        ))
2664    }
2665
2666    fn service(label: &str) -> SessionHistory {
2667        let root = root(label);
2668        std::fs::create_dir_all(&root).unwrap();
2669        SessionHistory::open(Config {
2670            directory: root.join("sessions"),
2671            completed_list: root.join("session-history.txt"),
2672            provider_cost_compatibility: None,
2673        })
2674        .unwrap()
2675    }
2676
2677    fn no_legacy_cost(
2678        _: &str,
2679        _: &chatend::ProviderMetering,
2680    ) -> Option<chatend::ProviderCostEstimate> {
2681        None
2682    }
2683
2684    #[test]
2685    fn metadata_free_session_log_archives_are_not_misclassified_as_chatend_archives() {
2686        let root = root("metadata-free-session-log-archive");
2687        std::fs::create_dir_all(&root).unwrap();
2688        let service = SessionHistory::open(Config {
2689            directory: root.join("sessions"),
2690            completed_list: root.join("session-history.txt"),
2691            provider_cost_compatibility: Some(ProviderCostCompatibility {
2692                session_model: |_| Some("gpt-test".into()),
2693                estimator: no_legacy_cost,
2694            }),
2695        })
2696        .unwrap();
2697        let archive = json!({
2698            "header": {
2699                "formatVersion": kcode_session_log::FORMAT_VERSION,
2700                "sessionId": "e9469f2a-003a-4717-9e38-0f2d79fe9218",
2701                "createdAt": "2026-07-24T13:04:28.017Z"
2702            },
2703            "events": [{
2704                "role": "user-message",
2705                "text": "This valid pre-Session-History archive has no Chatend metadata."
2706            }]
2707        });
2708
2709        assert_eq!(
2710            service
2711                .legacy_provider_cost_summary_for_archive(&archive, None)
2712                .unwrap(),
2713            None
2714        );
2715        std::fs::remove_dir_all(root).unwrap();
2716    }
2717
2718    async fn start(service: &SessionHistory, idempotency_id: &str) -> SessionRecord {
2719        service
2720            .start(StartSession {
2721                idempotency_id: idempotency_id.into(),
2722                started_at: "2026-07-23T00:00:00Z".into(),
2723                session_type: "conversation".into(),
2724                duration_minutes: None,
2725                custom_prompt: None,
2726            })
2727            .await
2728            .unwrap()
2729            .value
2730    }
2731
2732    #[test]
2733    fn control_state_retains_only_authoritative_lifecycle_and_recovery_fields() {
2734        let saved = control_state(&json!({
2735            "sessionType":"conversation",
2736            "pendingTurn":true,
2737            "transcript":[{"role":"user","content":"hello"}],
2738            "boxCount":1,
2739            "eventCount":1,
2740            "boxes":{"1":{"id":1}},
2741            "events":[{"id":1}],
2742            "context":{"estimatedTokens":10},
2743            "chatendMetadata":{
2744                "sessionId":"session-1",
2745                "kind":"conversation",
2746                "createdAt":"2026-07-23T00:00:00Z",
2747                "effectiveContextTokens":100
2748            },
2749            "sessionStatus":{"currentContextTokens":10,"contextLimitTokens":100},
2750            "chatendText":"hello",
2751            "historyIngress":{
2752                "format":"kennedy-chatend",
2753                "sessionType":"history-ingress",
2754                "chatendMetadata":{
2755                    "sessionId":"session-1",
2756                    "kind":"history_ingress",
2757                    "createdAt":"2026-07-23T00:00:00Z",
2758                    "effectiveContextTokens":100
2759                },
2760                "completed":true,
2761                "commitReceipt":{"sessionObjectId":"A1234567"},
2762                "boxes":{"2":{"id":2}},
2763                "events":[{"id":2}],
2764                "context":{"estimatedTokens":20},
2765                "sessionStatus":{"currentContextTokens":20,"contextLimitTokens":100},
2766                "chatendText":"ingress"
2767            },
2768            "unrecognized":"discard me"
2769        }));
2770        assert_eq!(saved["sessionType"], "conversation");
2771        assert_eq!(saved["pendingTurn"], true);
2772        assert_eq!(saved["chatendMetadata"]["kind"], "conversation");
2773        assert_eq!(saved["sessionStatus"]["currentContextTokens"], 10);
2774        assert_eq!(saved["historyIngress"]["sessionType"], "history-ingress");
2775        assert_eq!(
2776            saved["historyIngress"]["chatendMetadata"]["kind"],
2777            "history_ingress"
2778        );
2779        assert_eq!(saved["historyIngress"]["completed"], true);
2780        assert_eq!(
2781            saved["historyIngress"]["sessionStatus"]["currentContextTokens"],
2782            20
2783        );
2784        assert_eq!(
2785            saved["historyIngress"]["commitReceipt"]["sessionObjectId"],
2786            "A1234567"
2787        );
2788        for field in [
2789            "transcript",
2790            "boxCount",
2791            "eventCount",
2792            "boxes",
2793            "events",
2794            "context",
2795            "chatendText",
2796        ] {
2797            assert!(saved.get(field).is_none(), "{field} was retained");
2798            assert!(
2799                saved["historyIngress"].get(field).is_none(),
2800                "nested {field} was retained"
2801            );
2802        }
2803        assert!(saved.get("unrecognized").is_none());
2804        assert!(
2805            control_state(&json!({"commitReceipt":null}))
2806                .get("commitReceipt")
2807                .is_none()
2808        );
2809    }
2810
2811    #[test]
2812    fn startup_compacts_superseded_control_records_without_losing_recovery_state() {
2813        let root = root("control-compaction");
2814        let sessions = root.join("sessions");
2815        std::fs::create_dir_all(&sessions).unwrap();
2816        let id = "9078bb6e-0931-4477-9ce9-b1430d0335a2";
2817        drop(
2818            SessionStore::new(&sessions)
2819                .create_session(id, "2026-07-24T00:00:00Z")
2820                .unwrap(),
2821        );
2822        let control_path = control_path(&sessions, id);
2823        let mut file = File::create(&control_path).unwrap();
2824        let base = SessionRecord {
2825            id: id.into(),
2826            phase: "active".into(),
2827            started_at: "2026-07-24T00:00:00Z".into(),
2828            updated_at: "2026-07-24T00:00:00Z".into(),
2829            state: json!({
2830                "sessionType":"conversation",
2831                "boxes":{"1":{"canonical":{"content":{"text":"x".repeat(20_000)}}}},
2832                "events":[{"kind":"old presentation"}],
2833                "context":{"estimatedTokens":5_000},
2834                "chatendText":"x".repeat(20_000),
2835            }),
2836            provenance_id: None,
2837            version: 1,
2838            last_user_message_at: None,
2839            ended_at: None,
2840            ingress_failure_count: 0,
2841            ingress_failures: json!([]),
2842            ingress_next_attempt_at: None,
2843            summary: false,
2844        };
2845        write_control_record(
2846            &mut file,
2847            &ControlRecord {
2848                kind: LIFECYCLE_SIDEBAND.into(),
2849                recorded_at: "2026-07-24T00:00:00Z".into(),
2850                value: serde_json::to_value(&base).unwrap(),
2851            },
2852        )
2853        .unwrap();
2854        let pending_command = SessionCommand {
2855            id: "command-1".into(),
2856            conversation_id: id.into(),
2857            sequence: 1,
2858            kind: "message".into(),
2859            payload: json!({"text":"hello"}),
2860            status: "pending".into(),
2861            cancel_requested: false,
2862            outcome: None,
2863            created_at: "2026-07-24T00:00:01Z".into(),
2864            processing_started_at: None,
2865            completed_at: None,
2866            idempotency_id: "message-1".into(),
2867        };
2868        write_control_record(
2869            &mut file,
2870            &ControlRecord {
2871                kind: COMMAND_SIDEBAND.into(),
2872                recorded_at: "2026-07-24T00:00:01Z".into(),
2873                value: serde_json::to_value(&pending_command).unwrap(),
2874            },
2875        )
2876        .unwrap();
2877        let mut latest = base;
2878        latest.version = 2;
2879        latest.updated_at = "2026-07-24T00:00:02Z".into();
2880        latest.state["pendingTurn"] = json!(true);
2881        latest.state["historyIngress"] = json!({
2882            "format":"kennedy-chatend",
2883            "sessionType":"history-ingress",
2884            "completed":true,
2885            "commitReceipt":{"sessionObjectId":"A1234567"},
2886            "boxes":{"2":{"canonical":{"content":{"text":"y".repeat(20_000)}}}},
2887            "events":[{"kind":"ingress presentation"}],
2888            "context":{"estimatedTokens":7_000},
2889            "chatendText":"y".repeat(20_000),
2890        });
2891        write_control_record(
2892            &mut file,
2893            &ControlRecord {
2894                kind: LIFECYCLE_SIDEBAND.into(),
2895                recorded_at: "2026-07-24T00:00:02Z".into(),
2896                value: serde_json::to_value(&latest).unwrap(),
2897            },
2898        )
2899        .unwrap();
2900        let mut completed_command = pending_command;
2901        completed_command.status = "complete".into();
2902        completed_command.outcome = Some(json!({"accepted":true}));
2903        completed_command.completed_at = Some("2026-07-24T00:00:03Z".into());
2904        write_control_record(
2905            &mut file,
2906            &ControlRecord {
2907                kind: COMMAND_SIDEBAND.into(),
2908                recorded_at: "2026-07-24T00:00:03Z".into(),
2909                value: serde_json::to_value(&completed_command).unwrap(),
2910            },
2911        )
2912        .unwrap();
2913        file.sync_all().unwrap();
2914        drop(file);
2915        let original_bytes = std::fs::metadata(&control_path).unwrap().len();
2916
2917        let service = SessionHistory::open(Config {
2918            directory: sessions.clone(),
2919            completed_list: root.join("session-history.txt"),
2920            provider_cost_compatibility: None,
2921        })
2922        .unwrap();
2923        let compacted_bytes = std::fs::metadata(&control_path).unwrap().len();
2924        assert!(compacted_bytes < original_bytes / 10);
2925        let journal = open_by_id(&service.state, id).unwrap();
2926        assert_eq!(journal.records().len(), 2);
2927        let restored = latest_lifecycle(&journal).unwrap();
2928        assert_eq!(restored.version, 2);
2929        assert_eq!(restored.state["pendingTurn"], true);
2930        assert!(restored.state.get("boxes").is_none());
2931        assert!(restored.state.get("events").is_none());
2932        assert!(restored.state.get("context").is_none());
2933        assert!(restored.state.get("chatendText").is_none());
2934        assert_eq!(
2935            restored.state["historyIngress"]["commitReceipt"]["sessionObjectId"],
2936            "A1234567"
2937        );
2938        assert!(restored.state["historyIngress"].get("boxes").is_none());
2939        let restored_commands = commands(&journal);
2940        assert_eq!(restored_commands.len(), 1);
2941        assert_eq!(restored_commands["command-1"].status, "complete");
2942        assert_eq!(
2943            restored_commands["command-1"].outcome,
2944            Some(json!({"accepted":true}))
2945        );
2946    }
2947
2948    #[tokio::test]
2949    async fn managed_session_log_and_control_journal_remain_coordinated() {
2950        let service = service("commands");
2951        let record = start(&service, "start-1").await;
2952        let id = record.id.clone();
2953        let mut journal = open_by_id(&service.state, &id).unwrap();
2954        journal
2955            .log
2956            .add_event(Role::SystemError, "The message exceeded capacity.")
2957            .unwrap();
2958        drop(journal);
2959        let command = service
2960            .enqueue(
2961                &id,
2962                NewCommand {
2963                    idempotency_id: "message-1".into(),
2964                    kind: "message".into(),
2965                    payload: json!({"text":"hello"}),
2966                },
2967            )
2968            .await
2969            .unwrap()
2970            .value;
2971        assert_eq!(command.status, "pending");
2972        let materialized = service.get(&id).await.unwrap();
2973        assert!(materialized.state.get("chatendText").is_none());
2974        assert_eq!(
2975            materialized.state["transcript"][0],
2976            json!({
2977                "role":"system",
2978                "content":"The message exceeded capacity.",
2979                "boxId":1,
2980            })
2981        );
2982        let listed = service.command_heads().await.unwrap();
2983        assert_eq!(listed.len(), 1);
2984        assert_eq!(
2985            std::fs::read_dir(&service.state.config.directory)
2986                .unwrap()
2987                .count(),
2988            2
2989        );
2990    }
2991
2992    #[tokio::test]
2993    async fn stop_requests_are_durable_without_advancing_lifecycle() {
2994        let service = service("stop-requests");
2995        let record = start(&service, "start-1").await;
2996        let command = service
2997            .enqueue(
2998                &record.id,
2999                NewCommand {
3000                    idempotency_id: "message-1".into(),
3001                    kind: "message".into(),
3002                    payload: json!({"text":"hello"}),
3003                },
3004            )
3005            .await
3006            .unwrap()
3007            .value;
3008        service.claim_command(&command.id).await.unwrap();
3009
3010        let created = service
3011            .request_stop(
3012                &record.id,
3013                NewStopRequest {
3014                    idempotency_id: "stop-1".into(),
3015                    scope: "turn".into(),
3016                },
3017            )
3018            .await
3019            .unwrap();
3020        assert!(created.created);
3021        assert_eq!(created.value.scope, "turn");
3022        assert_eq!(created.value.status, "pending");
3023
3024        let unchanged = service.get(&record.id).await.unwrap();
3025        assert_eq!(unchanged.phase, "active");
3026        assert_eq!(unchanged.version, record.version);
3027        assert!(
3028            service.command_heads().await.unwrap()[0].cancel_requested,
3029            "turn stop must also cancel the active browser command"
3030        );
3031        assert_eq!(
3032            service.stop_heads().await.unwrap(),
3033            vec![created.value.clone()]
3034        );
3035
3036        let completed = service
3037            .complete_stop(
3038                &created.value.id,
3039                StopOutcome {
3040                    outcome: json!({"status":"stopped"}),
3041                },
3042            )
3043            .await
3044            .unwrap();
3045        assert_eq!(completed.status, "complete");
3046        assert_eq!(completed.outcome, Some(json!({"status":"stopped"})));
3047        assert!(service.stop_heads().await.unwrap().is_empty());
3048    }
3049
3050    #[tokio::test]
3051    async fn current_work_stop_derives_scope_and_notifies_live_or_late_listeners() {
3052        let service = service("current-work-stop");
3053        let conversation = start(&service, "conversation-start").await;
3054        let live = service.listen_for_stop(&conversation.id).unwrap();
3055        let requested = service
3056            .request_current_work_stop(
3057                &conversation.id,
3058                NewCurrentWorkStop {
3059                    idempotency_id: "conversation-stop".into(),
3060                },
3061            )
3062            .await
3063            .unwrap()
3064            .value;
3065        assert_eq!(requested.scope, "turn");
3066        tokio::time::timeout(std::time::Duration::from_secs(1), live.requested())
3067            .await
3068            .expect("live stop listener was not notified");
3069
3070        let self_time = service
3071            .start(StartSession {
3072                idempotency_id: "self-time-start".into(),
3073                started_at: "2026-07-23T01:00:00Z".into(),
3074                session_type: "free-time".into(),
3075                duration_minutes: Some(5.0),
3076                custom_prompt: None,
3077            })
3078            .await
3079            .unwrap()
3080            .value;
3081        let requested = service
3082            .request_current_work_stop(
3083                &self_time.id,
3084                NewCurrentWorkStop {
3085                    idempotency_id: "self-time-stop".into(),
3086                },
3087            )
3088            .await
3089            .unwrap()
3090            .value;
3091        assert_eq!(requested.scope, "self-time-run");
3092        let late = service.listen_for_stop(&self_time.id).unwrap();
3093        tokio::time::timeout(std::time::Duration::from_secs(1), late.requested())
3094            .await
3095            .expect("durable stop did not signal a late listener");
3096    }
3097
3098    #[test]
3099    fn current_work_stop_scope_uses_typed_kind_and_durable_phase() {
3100        let record = |phase: &str, kind: chatend::SessionKind| SessionRecord {
3101            id: Uuid::new_v4().to_string(),
3102            phase: phase.into(),
3103            started_at: "2026-08-01T00:00:00Z".into(),
3104            updated_at: "2026-08-01T00:00:00Z".into(),
3105            state: json!({
3106                "sessionType":"wakeup",
3107                "chatendMetadata":chatend::SessionMetadata {
3108                    session_id: Uuid::new_v4().to_string(),
3109                    kind,
3110                    created_at: "2026-08-01T00:00:00Z".into(),
3111                    effective_context_tokens: 1,
3112                    channel: Value::Null,
3113                },
3114            }),
3115            provenance_id: None,
3116            version: 1,
3117            last_user_message_at: None,
3118            ended_at: None,
3119            ingress_failure_count: 0,
3120            ingress_failures: json!([]),
3121            ingress_next_attempt_at: None,
3122            summary: false,
3123        };
3124
3125        let mut restored_conversation = record("active", chatend::SessionKind::Telegram);
3126        restored_conversation.state["historyIngress"] = json!({
3127            "chatendMetadata":chatend::SessionMetadata {
3128                session_id: Uuid::new_v4().to_string(),
3129                kind: chatend::SessionKind::HistoryIngress,
3130                created_at: "2026-08-01T00:00:00Z".into(),
3131                effective_context_tokens: 1,
3132                channel: Value::Null,
3133            },
3134        });
3135        assert_eq!(
3136            current_work_stop_scope(&restored_conversation).unwrap(),
3137            "turn"
3138        );
3139        assert_eq!(
3140            current_work_stop_scope(&record("active", chatend::SessionKind::SelfTime)).unwrap(),
3141            "self-time-run"
3142        );
3143        assert_eq!(
3144            current_work_stop_scope(&record(
3145                "ingress_in_progress",
3146                chatend::SessionKind::Telegram
3147            ))
3148            .unwrap(),
3149            "session"
3150        );
3151        assert_eq!(
3152            current_work_stop_scope(&record(
3153                "active",
3154                chatend::SessionKind::Other("wakeup".into())
3155            ))
3156            .unwrap(),
3157            "session"
3158        );
3159    }
3160
3161    #[tokio::test]
3162    async fn turn_stop_does_not_hide_or_cancel_an_end_command() {
3163        let service = service("stop-during-end");
3164        let record = start(&service, "start-1").await;
3165        let command = service
3166            .enqueue(
3167                &record.id,
3168                NewCommand {
3169                    idempotency_id: "end-1".into(),
3170                    kind: "end".into(),
3171                    payload: json!({}),
3172                },
3173            )
3174            .await
3175            .unwrap()
3176            .value;
3177        service
3178            .request_stop(
3179                &record.id,
3180                NewStopRequest {
3181                    idempotency_id: "stop-1".into(),
3182                    scope: "turn".into(),
3183                },
3184            )
3185            .await
3186            .unwrap();
3187        let head = &service.command_heads().await.unwrap()[0];
3188        assert_eq!(head.id, command.id);
3189        assert_eq!(head.kind, "end");
3190        assert!(!head.cancel_requested);
3191    }
3192
3193    #[tokio::test]
3194    async fn checkpointed_semantic_presentation_is_rebuilt_from_the_session_log() {
3195        let service = service("live-ui");
3196        let record = start(&service, "live-ui-start").await;
3197        let id = record.id.clone();
3198        let mut journal = open_by_id(&service.state, &id).unwrap();
3199        journal
3200            .log
3201            .add_event(Role::UserMessage, "hello from the log")
3202            .unwrap();
3203        drop(journal);
3204        let boxes = json!({
3205            "1":{
3206                "id":1,
3207                "owner":{"kind":"user"},
3208                "canonical":{"eventId":1,"content":{"text":"hello"}},
3209                "representation":{"kind":"hydrated"},
3210                "active":true
3211            }
3212        });
3213        let context = json!({"items":[{"boxId":1,"text":"hello"}]});
3214        let checkpointed = service
3215            .checkpoint(
3216                &id,
3217                Checkpoint {
3218                    expected_version: record.version,
3219                    state: json!({
3220                        "sessionId":id,
3221                        "sessionType":"conversation",
3222                        "boxes":boxes,
3223                        "events":[],
3224                        "context":context,
3225                        "chatendText":"hello",
3226                        "historyIngress":{"format":"kennedy-chatend","version":1}
3227                    }),
3228                    user_activity: false,
3229                },
3230            )
3231            .await
3232            .unwrap();
3233        assert!(checkpointed.state.get("boxes").is_none());
3234        assert!(checkpointed.state.get("context").is_none());
3235        assert_eq!(
3236            checkpointed.state["transcript"][0]["content"],
3237            "hello from the log"
3238        );
3239        assert_eq!(checkpointed.state["events"][0]["role"], "user-message");
3240        assert!(checkpointed.state.get("chatendText").is_none());
3241        assert_eq!(
3242            checkpointed.state["historyIngress"]["format"],
3243            "kennedy-chatend"
3244        );
3245
3246        let fetched = service.get(&id).await.unwrap();
3247        assert!(fetched.state.get("boxes").is_none());
3248        assert!(fetched.state.get("context").is_none());
3249        assert!(fetched.state.get("chatendText").is_none());
3250        assert_eq!(
3251            fetched.state["transcript"][0]["content"],
3252            "hello from the log"
3253        );
3254    }
3255
3256    #[tokio::test]
3257    async fn materialized_context_is_the_authoritative_chatend_rendering() {
3258        let service = service("exact-context");
3259        let mut session = service
3260            .create_session(NewSession {
3261                kind: chatend::SessionKind::Conversation,
3262                created_at: "2026-07-23T00:00:00Z".into(),
3263                effective_context_tokens: 100_000,
3264                channel: Value::Null,
3265            })
3266            .unwrap();
3267        session
3268            .create_box(
3269                "2026-07-23T00:00:01Z",
3270                "User message",
3271                chatend::BoxOwner::User,
3272                chatend::BoxContent::text("literal \\\\n stays escaped\nactual newline"),
3273            )
3274            .unwrap();
3275        let id = session.id().to_owned();
3276        let metadata = session.state().metadata.clone();
3277        drop(session);
3278
3279        service
3280            .register(RegisterSession {
3281                id: id.clone(),
3282                started_at: "2026-07-23T00:00:00Z".into(),
3283                state: json!({
3284                    "sessionId":id,
3285                    "sessionType":"conversation",
3286                    "chatendMetadata":metadata,
3287                    "chatendText":"must not survive control projection",
3288                }),
3289            })
3290            .await
3291            .unwrap();
3292        let materialized = service.get(&id).await.unwrap();
3293        let chatend_text = materialized.state["chatendText"].as_str().unwrap();
3294        assert!(
3295            chatend_text
3296                .contains("[box 1 | User message | timestamp=2026-07-23T00:00:01Z | hydrated]")
3297        );
3298        assert!(chatend_text.contains("[current time: 20"));
3299        assert_eq!(
3300            materialized.state["context"]["contextBytes"],
3301            chatend_text.len() as u64
3302        );
3303        assert_eq!(
3304            materialized.state["boxes"]["1"]["canonical"]["content"]["text"],
3305            "literal \\\\n stays escaped\nactual newline"
3306        );
3307    }
3308
3309    #[tokio::test]
3310    async fn active_pending_objects_are_streamed_with_original_metadata() {
3311        let service = service("pending-object");
3312        let record = start(&service, "pending-object-start").await;
3313        let id = record.id;
3314        let mut journal = open_by_id(&service.state, &id).unwrap();
3315        let position = journal
3316            .log
3317            .add_pending_object(
3318                "object event",
3319                "photo.png",
3320                "image/png",
3321                b"\x89PNG\r\n\x1a\npayload",
3322            )
3323            .unwrap();
3324        let object = service
3325            .object(&id, &format!("pending:{}", position.index() + 1))
3326            .unwrap();
3327        assert_eq!(object.media_type, "image/png");
3328        assert_eq!(object.file_name, "photo.png");
3329        assert_eq!(object.bytes, b"\x89PNG\r\n\x1a\npayload");
3330    }
3331
3332    #[tokio::test]
3333    async fn conversation_ingress_failures_back_off_then_stop_and_can_be_retried() {
3334        let service = service("ingress-retries");
3335        let created = start(&service, "ingress-retries-start").await;
3336        let id = created.id.clone();
3337        let mut record = service
3338            .request_ingress(
3339                &id,
3340                Checkpoint {
3341                    expected_version: created.version,
3342                    state: created.state,
3343                    user_activity: false,
3344                },
3345            )
3346            .await
3347            .unwrap();
3348
3349        for attempt in 1..=INGRESS_FAILURE_LIMIT {
3350            record = service
3351                .start_ingress(
3352                    &id,
3353                    StartIngress {
3354                        expected_version: record.version,
3355                        provenance_id: format!("session:{id}"),
3356                    },
3357                )
3358                .await
3359                .unwrap();
3360            record = service
3361                .fail_ingress(
3362                    &id,
3363                    IngressFailure {
3364                        expected_version: record.version,
3365                        stage: "model_loop".into(),
3366                        code: Some("ingress_error".into()),
3367                        message: "transient failure".into(),
3368                        rounds_used: None,
3369                        context_tokens: None,
3370                        context_window_tokens: None,
3371                    },
3372                )
3373                .await
3374                .unwrap();
3375            assert_eq!(record.ingress_failure_count, attempt);
3376            if attempt < INGRESS_FAILURE_LIMIT {
3377                assert_eq!(record.phase, "ingress_pending");
3378                assert!(record.ingress_next_attempt_at.is_some());
3379            } else {
3380                assert_eq!(record.phase, "ingress_failed");
3381                assert!(record.ingress_next_attempt_at.is_none());
3382            }
3383        }
3384        assert_eq!(
3385            record.ingress_failures.as_array().unwrap().len(),
3386            RETAINED_INGRESS_FAILURES
3387        );
3388
3389        let retried = service
3390            .retry_ingress(
3391                &id,
3392                RetryIngress {
3393                    expected_version: record.version,
3394                    state: record.state,
3395                },
3396            )
3397            .await
3398            .unwrap();
3399        assert_eq!(retried.phase, "ingress_pending");
3400        assert_eq!(retried.ingress_failure_count, 0);
3401        assert!(retried.ingress_next_attempt_at.is_none());
3402    }
3403
3404    #[tokio::test]
3405    async fn prepared_ingress_is_idempotent_through_completion() {
3406        let service = service("prepared-ingress");
3407        let input = NewIngressSession {
3408            idempotency_id: "audio:recording:0".into(),
3409            started_at: "2026-01-02T03:04:05Z".into(),
3410            source_session_type: "audio".into(),
3411            kind: chatend::SessionKind::AudioIngress,
3412            effective_context_tokens: 100_000,
3413            text: "Prepared transcript".into(),
3414            metadata: json!({
3415                "kind":"audio-transcript",
3416                "recordingId":"recording",
3417                "pieceIndex":0,
3418                "pieceCount":1,
3419            }),
3420        };
3421        let created = service.enqueue_ingress(input.clone()).await.unwrap();
3422        assert!(created.created);
3423        assert_eq!(created.value.phase, "ingress_pending");
3424        assert_eq!(
3425            created.value.state["transcript"][0]["content"],
3426            "Prepared transcript"
3427        );
3428        assert_eq!(
3429            created.value.state["ingressSource"]["metadata"]["recordingId"],
3430            "recording"
3431        );
3432
3433        let replay = service.enqueue_ingress(input.clone()).await.unwrap();
3434        assert!(!replay.created);
3435        assert_eq!(replay.value.id, created.value.id);
3436
3437        let mut state = replay.value.state;
3438        state["historyIngress"] = json!({
3439            "completed":true,
3440            "sessionObjectId":"A1234567",
3441            "commitReceipt":{
3442                "sessionObjectId":"A1234567",
3443                "nodeIds":{},
3444                "objectIds":{}
3445            }
3446        });
3447        let checkpointed = service
3448            .checkpoint(
3449                &created.value.id,
3450                Checkpoint {
3451                    expected_version: replay.value.version,
3452                    state,
3453                    user_activity: false,
3454                },
3455            )
3456            .await
3457            .unwrap();
3458        service
3459            .complete_ingress(
3460                &created.value.id,
3461                ExpectedVersion {
3462                    expected_version: checkpointed.version,
3463                },
3464            )
3465            .await
3466            .unwrap();
3467
3468        let completed_replay = service.enqueue_ingress(input).await.unwrap();
3469        assert!(!completed_replay.created);
3470        assert_eq!(completed_replay.value.phase, "complete");
3471        assert_eq!(
3472            completed_replay.value.state["ingressSource"]["metadata"]["recordingId"],
3473            "recording"
3474        );
3475    }
3476
3477    #[tokio::test]
3478    async fn startup_releases_legacy_hot_loop_without_discarding_checkpoint_state() {
3479        let service = service("ingress-release");
3480        let created = start(&service, "ingress-release-start").await;
3481        let id = created.id.clone();
3482        let pending = service
3483            .request_ingress(
3484                &id,
3485                Checkpoint {
3486                    expected_version: created.version,
3487                    state: json!({
3488                        "sessionType":"conversation",
3489                        "historyIngress":{"kwebPlan":{"creates":[{"pendingId":"pending:9"}]}}
3490                    }),
3491                    user_activity: false,
3492                },
3493            )
3494            .await
3495            .unwrap();
3496        let started = service
3497            .start_ingress(
3498                &id,
3499                StartIngress {
3500                    expected_version: pending.version,
3501                    provenance_id: format!("session:{id}"),
3502                },
3503            )
3504            .await
3505            .unwrap();
3506        let mut journal = open_by_id(&service.state, &id).unwrap();
3507        let mut legacy = started;
3508        legacy.ingress_failure_count = 150;
3509        legacy.ingress_failures = Value::Array(
3510            (1..=150)
3511                .map(|attempt| json!({"attempt":attempt,"message":"legacy hot loop"}))
3512                .collect(),
3513        );
3514        legacy.version += 1;
3515        append_lifecycle(&mut journal, &legacy).unwrap();
3516        drop(journal);
3517
3518        let released = service.release_interrupted_ingress().await.unwrap();
3519        assert_eq!(released, vec![id.clone()]);
3520        let repaired = service.get(&id).await.unwrap();
3521        assert_eq!(repaired.phase, "ingress_pending");
3522        assert_eq!(repaired.ingress_failure_count, 0);
3523        assert_eq!(
3524            repaired.ingress_failures.as_array().unwrap().len(),
3525            RETAINED_INGRESS_FAILURES
3526        );
3527        assert_eq!(
3528            repaired.state["historyIngress"]["kwebPlan"]["creates"][0]["pendingId"],
3529            "pending:9"
3530        );
3531    }
3532
3533    #[tokio::test]
3534    async fn nested_history_ingress_receipt_completes_the_source_session() {
3535        let service = service("nested-completion");
3536        let record = start(&service, "nested-completion-start").await;
3537        let id = record.id.clone();
3538        let mut state = record.state.clone();
3539        state["historyIngress"] = json!({
3540            "sessionType":"history-ingress",
3541            "completed":true,
3542            "sessionObjectId":"A1234567",
3543            "commitReceipt":{
3544                "transactionId":"T1234567",
3545                "sessionObjectId":"A1234567",
3546                "nodeIds":{},
3547                "objectIds":{}
3548            }
3549        });
3550        let completed = service
3551            .complete(
3552                &id,
3553                Checkpoint {
3554                    expected_version: record.version,
3555                    state,
3556                    user_activity: false,
3557                },
3558            )
3559            .await
3560            .unwrap();
3561        assert_eq!(completed.phase, "complete");
3562        assert_eq!(completed.state["sessionObjectId"], "A1234567");
3563        assert_eq!(
3564            completed.state["commitReceipt"]["transactionId"],
3565            "T1234567"
3566        );
3567        assert!(
3568            !service
3569                .state
3570                .config
3571                .directory
3572                .join(format!("{id}.session-log"))
3573                .exists()
3574        );
3575        assert!(!control_path(&service.state.config.directory, &id).exists());
3576        assert_eq!(
3577            read_completion_receipts(&service.state.config.completed_list).unwrap()[0]
3578                .session_object_id,
3579            "A1234567"
3580        );
3581    }
3582
3583    #[tokio::test]
3584    async fn listing_tolerates_a_session_completing_after_path_enumeration() {
3585        let service = service("concurrent-completion-list");
3586        let record = start(&service, "concurrent-completion-list-start").await;
3587        let id = record.id.clone();
3588        let listed_paths = journal_paths(&service.state.config.directory).unwrap();
3589        assert_eq!(listed_paths.len(), 1);
3590
3591        let mut state = record.state;
3592        state["sessionObjectId"] = json!("A1234567");
3593        service
3594            .complete(
3595                &id,
3596                Checkpoint {
3597                    expected_version: record.version,
3598                    state,
3599                    user_activity: false,
3600                },
3601            )
3602            .await
3603            .unwrap();
3604
3605        assert!(open_listed_journals(listed_paths).unwrap().is_empty());
3606        let listed = service.list().await.unwrap();
3607        assert_eq!(listed.len(), 1);
3608        assert_eq!(listed[0].phase, "complete");
3609        assert_eq!(listed[0].state["sessionObjectId"], "A1234567");
3610    }
3611
3612    #[tokio::test]
3613    async fn committed_history_records_a_structured_receipt() {
3614        let service = service("completed");
3615        append_completed_id(&service.state.config.completed_list, "A1234567").unwrap();
3616        let listed = service.list().await.unwrap();
3617        assert_eq!(listed[0].state["sessionObjectId"], "A1234567");
3618        assert_eq!(
3619            listed[0].state["commitReceipt"]["sessionObjectId"],
3620            "A1234567"
3621        );
3622        let stored = std::fs::read_to_string(&service.state.config.completed_list).unwrap();
3623        let receipt: CompletionReceipt = serde_json::from_str(stored.trim()).unwrap();
3624        assert_eq!(receipt.session_object_id, "A1234567");
3625    }
3626}