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