use std::sync::Arc;
use std::time::{Duration, Instant};
use serde_json::Value as JsonValue;
use time::OffsetDateTime;
use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
use toolkit_macros::domain_model;
use toolkit_odata::{ODataQuery, Page};
use tracing::{info, warn};
use uuid::Uuid;
use chat_engine_sdk::models::{Capability, CapabilityValue, LifecycleState, TenantId, UserId};
use chat_engine_sdk::plugin::{PluginCallContext, SessionPluginCtx};
use crate::domain::error::{ChatEngineError, Result};
use crate::domain::ports::{DEFAULT_SOFT_DELETE_RETENTION_DAYS, NewSession, SessionRepo};
use crate::domain::ports::{NewSessionType, SessionTypeRepo};
use crate::domain::service::plugin_service::PluginService;
use crate::domain::service::webhook::{WebhookEmitter, WebhookEvent};
use crate::domain::session::{
RESERVED_METADATA_KEYS, Session, SessionType, ensure_can_transition, public_metadata,
};
pub const DEFAULT_PLUGIN_CALL_TIMEOUT: Duration = Duration::from_secs(10);
#[domain_model]
#[derive(Debug, Clone)]
pub struct Identity {
pub tenant_id: String,
pub user_id: String,
pub client_id: Option<String>,
}
impl Identity {
pub fn new(
tenant_id: impl Into<String>,
user_id: impl Into<String>,
client_id: Option<String>,
) -> Result<Self> {
let tenant_id = tenant_id.into();
let user_id = user_id.into();
if tenant_id.is_empty() {
return Err(ChatEngineError::bad_request(
"tenant_id missing in identity",
));
}
if user_id.is_empty() {
return Err(ChatEngineError::bad_request("user_id missing in identity"));
}
Ok(Self {
tenant_id,
user_id,
client_id,
})
}
}
#[domain_model]
#[derive(Debug, Clone)]
pub struct RegisterSessionTypeRequest {
pub name: String,
pub plugin_instance_id: Option<String>,
pub plugin_config: Option<JsonValue>,
}
#[domain_model]
#[derive(Debug, Clone)]
pub struct CreateSessionRequest {
pub session_type_id: Option<Uuid>,
pub metadata: Option<JsonValue>,
}
#[allow(clippy::large_enum_variant)]
#[domain_model]
#[derive(Debug, Clone)]
pub enum SessionDeleteOutcome {
Soft { session: Session },
Hard,
}
#[domain_model]
#[derive(Clone)]
pub struct SessionService {
sessions: Arc<dyn SessionRepo>,
session_types: Arc<dyn SessionTypeRepo>,
plugins: PluginService,
webhooks: Arc<dyn WebhookEmitter>,
plugin_timeout: Duration,
}
impl SessionService {
#[must_use]
pub fn new(
sessions: Arc<dyn SessionRepo>,
session_types: Arc<dyn SessionTypeRepo>,
plugins: PluginService,
webhooks: Arc<dyn WebhookEmitter>,
) -> Self {
Self {
sessions,
session_types,
plugins,
webhooks,
plugin_timeout: DEFAULT_PLUGIN_CALL_TIMEOUT,
}
}
#[must_use]
pub fn with_plugin_timeout(mut self, timeout: Duration) -> Self {
self.plugin_timeout = timeout;
self
}
pub async fn register_session_type(
&self,
identity: &Identity,
req: RegisterSessionTypeRequest,
) -> Result<SessionType> {
if req.name.trim().is_empty() {
return Err(ChatEngineError::bad_request(
"session-type name must not be empty",
));
}
let session_type_id = Uuid::new_v4();
let now = OffsetDateTime::now_utc();
let inserted = self
.session_types
.insert(NewSessionType {
session_type_id,
name: req.name.clone(),
plugin_instance_id: req.plugin_instance_id.clone(),
created_at: now,
updated_at: now,
})
.await?;
if let Some(plugin_instance_id) = req.plugin_instance_id.as_deref() {
let plugin = self.plugins.resolve(plugin_instance_id)?;
if let Some(cfg) = req.plugin_config.clone() {
self.plugins
.save_config(plugin_instance_id, session_type_id, cfg)
.await?;
}
let cancel = CancellationToken::new();
let ctx = PluginCallContext {
request_id: Uuid::new_v4(),
tenant_id: TenantId::new(identity.tenant_id.as_str()),
user_id: UserId::new(identity.user_id.as_str()),
plugin_instance_id: plugin_instance_id.to_string(),
session_type_id,
plugin_config: req.plugin_config.clone(),
enabled_capabilities: None,
deadline: Some(Instant::now() + self.plugin_timeout),
cancel: cancel.clone(),
};
let session_ctx = SessionPluginCtx {
session_type_id,
session_id: None,
call_ctx: ctx,
};
match self
.invoke_with_deadline(plugin.on_session_type_configured(session_ctx), &cancel)
.await
{
Ok(_result) => {
info!(
plugin_instance_id = %plugin_instance_id,
session_type_id = %session_type_id,
"session-type configured with plugin"
);
}
Err(err) => {
warn!(
plugin_instance_id = %plugin_instance_id,
session_type_id = %session_type_id,
error = %err,
"plugin on_session_type_configured failed \u{2014} registration kept (advisory)"
);
}
}
if let Err(err) = self.plugins.health_probe(plugin_instance_id).await {
warn!(
plugin_instance_id = %plugin_instance_id,
error = %err,
"plugin health probe failed during session-type registration (advisory)"
);
}
}
Ok(inserted)
}
pub async fn list_session_types(&self, _identity: &Identity) -> Result<Vec<SessionType>> {
self.session_types.list().await
}
pub async fn get_session_type(
&self,
_identity: &Identity,
session_type_id: Uuid,
) -> Result<SessionType> {
let row = self
.session_types
.find_by_id(session_type_id)
.await?
.ok_or_else(|| ChatEngineError::not_found("session_type", session_type_id))?;
Ok(row)
}
pub async fn create_session(
&self,
identity: &Identity,
req: CreateSessionRequest,
) -> Result<Session> {
reject_reserved_metadata(req.metadata.as_ref())?;
let session_type = match req.session_type_id {
Some(id) => Some(
self.session_types
.find_by_id(id)
.await?
.ok_or_else(|| ChatEngineError::not_found("session_type", id))?,
),
None => None,
};
let session_id = Uuid::new_v4();
let now = OffsetDateTime::now_utc();
let inserted = self
.sessions
.insert(NewSession {
session_id,
tenant_id: identity.tenant_id.clone(),
user_id: identity.user_id.clone(),
client_id: identity.client_id.clone(),
session_type_id: req.session_type_id,
metadata: req.metadata,
created_at: now,
updated_at: now,
})
.await?;
let inserted_id = inserted.session_id;
let mut enabled_capabilities: Option<JsonValue> = None;
let mut plugin_metadata: Option<JsonValue> = None;
if let Some(ref st) = session_type
&& let Some(plugin_instance_id) = st.plugin_instance_id.clone()
{
let plugin = self.plugins.resolve(&plugin_instance_id)?;
let plugin_config = self
.plugins
.load_config(&plugin_instance_id, st.session_type_id)
.await?;
let cancel = CancellationToken::new();
let call_ctx = PluginCallContext {
request_id: Uuid::new_v4(),
tenant_id: TenantId::new(identity.tenant_id.as_str()),
user_id: UserId::new(identity.user_id.as_str()),
plugin_instance_id: plugin_instance_id.clone(),
session_type_id: st.session_type_id,
plugin_config,
enabled_capabilities: None,
deadline: Some(Instant::now() + self.plugin_timeout),
cancel: cancel.clone(),
};
let session_ctx = SessionPluginCtx {
session_type_id: st.session_type_id,
session_id: Some(inserted_id),
call_ctx,
};
match self
.invoke_with_deadline(plugin.on_session_created(session_ctx), &cancel)
.await
{
Ok(result) => {
enabled_capabilities = Some(capabilities_to_json(result.capabilities));
plugin_metadata = result.metadata;
}
Err(err) => {
if let Err(rollback_err) = self
.sessions
.hard_delete(&identity.tenant_id, &identity.user_id, inserted_id)
.await
{
warn!(
session_id = %inserted_id,
plugin_error = %err,
rollback_error = %rollback_err,
"failed to roll back orphaned session row after plugin rejection",
);
return Err(ChatEngineError::internal(format!(
"session rollback failed after plugin error ({err}); \
session {inserted_id} may be orphaned"
)));
}
return Err(err);
}
}
}
let mut persisted = if let Some(caps) = enabled_capabilities {
self.sessions
.update_capabilities(
&identity.tenant_id,
&identity.user_id,
inserted_id,
Some(caps),
)
.await?
} else {
inserted
};
if let Some(plugin_meta) = plugin_metadata {
let merged = merge_plugin_metadata(persisted.metadata.clone(), plugin_meta);
persisted = self
.sessions
.update_metadata(
&identity.tenant_id,
&identity.user_id,
inserted_id,
Some(merged),
)
.await?;
}
let session: Session = persisted;
self.webhooks
.emit(WebhookEvent::SessionCreated {
session_id: session.session_id,
tenant_id: identity.tenant_id.clone(),
user_id: identity.user_id.clone(),
session_type_id: session.session_type_id,
})
.await
.unwrap_or_else(|err| {
warn!(error = %err, "webhook emit failed for session.created");
});
Ok(redact_session(session))
}
pub async fn get_session(&self, identity: &Identity, session_id: Uuid) -> Result<Session> {
let row = self
.sessions
.find_by_id(&identity.tenant_id, &identity.user_id, session_id)
.await?
.ok_or_else(|| ChatEngineError::not_found("session", session_id))?;
Ok(redact_session(row))
}
pub async fn list_sessions(
&self,
identity: &Identity,
query: &ODataQuery,
) -> Result<Page<Session>> {
let page = self
.sessions
.list_paginated(&identity.tenant_id, &identity.user_id, query)
.await?;
Ok(page.map_items(redact_session))
}
pub async fn update_metadata(
&self,
identity: &Identity,
session_id: Uuid,
metadata: JsonValue,
) -> Result<Session> {
reject_reserved_metadata(Some(&metadata))?;
let row = self.load_modifiable(identity, session_id).await?;
let state = row.lifecycle_state;
if matches!(
state,
LifecycleState::SoftDeleted | LifecycleState::HardDeleted
) {
return Err(ChatEngineError::conflict(
"session is deleted and cannot accept metadata updates",
));
}
let updated = self
.sessions
.update_metadata(
&identity.tenant_id,
&identity.user_id,
session_id,
Some(metadata),
)
.await?;
Ok(redact_session(updated))
}
pub async fn update_capabilities(
&self,
identity: &Identity,
session_id: Uuid,
caps: Vec<CapabilityValue>,
) -> Result<Session> {
let row = self.load_modifiable(identity, session_id).await?;
let state = row.lifecycle_state;
if matches!(
state,
LifecycleState::SoftDeleted | LifecycleState::HardDeleted
) {
return Err(ChatEngineError::conflict(
"session is deleted and cannot accept capability updates",
));
}
let session_type_id = row.session_type_id;
let plugin_instance_id = match session_type_id {
Some(st_id) => self
.session_types
.find_by_id(st_id)
.await?
.and_then(|st| st.plugin_instance_id),
None => None,
};
let mut new_caps_json = capability_values_to_json(&caps);
let mut plugin_metadata: Option<JsonValue> = None;
if let Some(ref plugin_instance_id) = plugin_instance_id {
let plugin = self.plugins.resolve(plugin_instance_id)?;
let plugin_config = match session_type_id {
Some(st_id) => self.plugins.load_config(plugin_instance_id, st_id).await?,
None => None,
};
let cancel = CancellationToken::new();
let call_ctx = PluginCallContext {
request_id: Uuid::new_v4(),
tenant_id: TenantId::new(identity.tenant_id.as_str()),
user_id: UserId::new(identity.user_id.as_str()),
plugin_instance_id: plugin_instance_id.clone(),
session_type_id: session_type_id.unwrap_or_else(Uuid::nil),
plugin_config,
enabled_capabilities: Some(caps.clone()),
deadline: Some(Instant::now() + self.plugin_timeout),
cancel: cancel.clone(),
};
let session_ctx = SessionPluginCtx {
session_type_id: session_type_id.unwrap_or_else(Uuid::nil),
session_id: Some(session_id),
call_ctx,
};
let returned = self
.invoke_with_deadline(plugin.on_session_updated(session_ctx), &cancel)
.await?;
new_caps_json = capabilities_to_json(returned.capabilities);
plugin_metadata = returned.metadata;
}
let mut updated = self
.sessions
.update_capabilities(
&identity.tenant_id,
&identity.user_id,
session_id,
Some(new_caps_json),
)
.await?;
if let Some(plugin_meta) = plugin_metadata {
let merged = merge_plugin_metadata(updated.metadata.clone(), plugin_meta);
updated = self
.sessions
.update_metadata(
&identity.tenant_id,
&identity.user_id,
session_id,
Some(merged),
)
.await?;
}
Ok(redact_session(updated))
}
pub async fn archive_session(&self, identity: &Identity, session_id: Uuid) -> Result<Session> {
let row = self.load_modifiable(identity, session_id).await?;
let from = row.lifecycle_state;
ensure_can_transition(from, LifecycleState::Archived)?;
let updated = self
.sessions
.update_lifecycle_state(
&identity.tenant_id,
&identity.user_id,
session_id,
LifecycleState::Archived,
)
.await?;
self.webhooks
.emit(WebhookEvent::SessionArchived {
session_id,
tenant_id: identity.tenant_id.clone(),
user_id: identity.user_id.clone(),
})
.await
.unwrap_or_else(|err| warn!(error = %err, "webhook emit failed for session.archived"));
Ok(redact_session(updated))
}
pub async fn restore_session(&self, identity: &Identity, session_id: Uuid) -> Result<Session> {
let row = self.load_modifiable(identity, session_id).await?;
let from = row.lifecycle_state;
ensure_can_transition(from, LifecycleState::Active)?;
let scheduled_hard_delete_at = self
.sessions
.scheduled_hard_delete_at(&identity.tenant_id, &identity.user_id, session_id)
.await?;
if let Some(scheduled) = scheduled_hard_delete_at
&& scheduled < OffsetDateTime::now_utc()
{
return Err(ChatEngineError::conflict(
"soft-delete grace period elapsed; session can no longer be restored",
));
}
let updated = self
.sessions
.update_lifecycle_state(
&identity.tenant_id,
&identity.user_id,
session_id,
LifecycleState::Active,
)
.await?;
self.webhooks
.emit(WebhookEvent::SessionRestored {
session_id,
tenant_id: identity.tenant_id.clone(),
user_id: identity.user_id.clone(),
})
.await
.unwrap_or_else(|err| warn!(error = %err, "webhook emit failed for session.restored"));
Ok(redact_session(updated))
}
pub async fn delete_session(
&self,
identity: &Identity,
session_id: Uuid,
hard: bool,
) -> Result<SessionDeleteOutcome> {
let row = self.load_modifiable(identity, session_id).await?;
let from = row.lifecycle_state;
let target = if hard {
LifecycleState::HardDeleted
} else {
LifecycleState::SoftDeleted
};
ensure_can_transition(from, target)?;
if hard {
let removed = self
.sessions
.hard_delete(&identity.tenant_id, &identity.user_id, session_id)
.await?;
if !removed {
return Err(ChatEngineError::not_found("session", session_id));
}
self.webhooks
.emit(WebhookEvent::SessionHardDeleted {
session_id,
tenant_id: identity.tenant_id.clone(),
user_id: identity.user_id.clone(),
})
.await
.unwrap_or_else(|err| {
warn!(error = %err, "webhook emit failed for session.hard_deleted");
});
Ok(SessionDeleteOutcome::Hard)
} else {
let retention_days = DEFAULT_SOFT_DELETE_RETENTION_DAYS;
let updated = self
.sessions
.soft_delete(
&identity.tenant_id,
&identity.user_id,
session_id,
retention_days,
)
.await?;
self.webhooks
.emit(WebhookEvent::SessionSoftDeleted {
session_id,
tenant_id: identity.tenant_id.clone(),
user_id: identity.user_id.clone(),
})
.await
.unwrap_or_else(|err| {
warn!(error = %err, "webhook emit failed for session.soft_deleted");
});
Ok(SessionDeleteOutcome::Soft {
session: redact_session(updated),
})
}
}
async fn load_modifiable(&self, identity: &Identity, session_id: Uuid) -> Result<Session> {
self.sessions
.find_by_id(&identity.tenant_id, &identity.user_id, session_id)
.await?
.ok_or_else(|| ChatEngineError::not_found("session", session_id))
}
async fn invoke_with_deadline<F, T>(&self, fut: F, cancel: &CancellationToken) -> Result<T>
where
F: std::future::Future<
Output = std::result::Result<T, chat_engine_sdk::error::PluginError>,
>,
{
match timeout(self.plugin_timeout, fut).await {
Ok(Ok(v)) => Ok(v),
Ok(Err(e)) => Err(e.into()),
Err(_elapsed) => {
cancel.cancel();
Err(chat_engine_sdk::error::PluginError::timeout().into())
}
}
}
}
pub fn reject_reserved_metadata(metadata: Option<&JsonValue>) -> Result<()> {
let Some(JsonValue::Object(map)) = metadata else {
return Ok(());
};
for key in RESERVED_METADATA_KEYS {
if map.contains_key(*key) {
return Err(ChatEngineError::bad_request(format!(
"metadata key '{key}' is reserved and cannot be set by clients"
)));
}
}
Ok(())
}
fn capabilities_to_json(caps: Vec<Capability>) -> JsonValue {
serde_json::to_value(caps).unwrap_or(JsonValue::Array(Vec::new()))
}
fn capability_values_to_json(caps: &[CapabilityValue]) -> JsonValue {
serde_json::to_value(caps).unwrap_or(JsonValue::Array(Vec::new()))
}
pub(crate) fn merge_plugin_metadata(base: Option<JsonValue>, overlay: JsonValue) -> JsonValue {
let overlay = match overlay {
JsonValue::Object(mut map) => {
map.retain(|k, _| !RESERVED_METADATA_KEYS.contains(&k.as_str()));
JsonValue::Object(map)
}
other => other,
};
match base {
Some(JsonValue::Object(mut base_map)) => {
if let JsonValue::Object(over_map) = overlay {
for (k, v) in over_map {
base_map.insert(k, v);
}
}
JsonValue::Object(base_map)
}
_ => overlay,
}
}
#[must_use]
pub fn redact_session(mut s: Session) -> Session {
s.metadata = public_metadata(&s);
s.share_token = None;
s
}
#[cfg(test)]
#[path = "session_service_tests.rs"]
mod session_service_tests;