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