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