use std::path::PathBuf;
use std::pin::Pin;
use std::time::SystemTime;
use futures_util::{Stream, StreamExt};
use crate::backends::custom::CustomNotificationReceiver;
use crate::core::notification_store::NotificationReceiver;
use crate::{KitError, NotificationId, WorkspaceContext};
use storage_kit::{DatabaseBackend, StateStoreBackend};
#[derive(Clone, Debug, PartialEq)]
pub struct ConversationNotification {
id: NotificationId,
inserted_at: SystemTime,
kind: ConversationNotificationKind,
}
impl ConversationNotification {
pub fn new(
id: NotificationId,
inserted_at: SystemTime,
kind: ConversationNotificationKind,
) -> Result<Self, KitError> {
kind.validate()?;
Ok(Self {
id,
inserted_at,
kind,
})
}
pub fn id(&self) -> &NotificationId {
&self.id
}
pub fn inserted_at(&self) -> SystemTime {
self.inserted_at
}
pub fn kind(&self) -> &ConversationNotificationKind {
&self.kind
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ProcessState {
Running,
Exited {
exit_code: Option<i32>,
},
}
#[derive(Clone, Debug, PartialEq)]
pub enum ConversationNotificationKind {
Message {
text: String,
},
FileChanged {
path: PathBuf,
summary: Option<String>,
},
Process {
label: String,
state: ProcessState,
},
Progress {
label: String,
detail: Option<String>,
},
Warning {
message: String,
},
Error {
message: String,
},
Interaction {
title: String,
body: String,
response_schema: Option<serde_json::Value>,
dismissible: bool,
},
}
impl ConversationNotificationKind {
fn validate(&self) -> Result<(), KitError> {
match self {
Self::Message { text }
| Self::Warning { message: text }
| Self::Error { message: text }
if text.trim().is_empty() =>
{
Err(KitError::invalid_input(
"notification message must be nonblank",
))
}
Self::FileChanged { path, summary } => {
WorkspaceContext::normalized_relative(path)?;
if summary
.as_ref()
.is_some_and(|summary| summary.trim().is_empty())
{
return Err(KitError::invalid_input(
"file notification summary must be absent or nonblank",
));
}
Ok(())
}
Self::Process { label, .. }
if label.is_empty()
|| label.trim() != label
|| label.chars().any(char::is_control) =>
{
Err(KitError::invalid_input(
"process notification label must be nonempty, unpadded, and control-free",
))
}
Self::Progress { label, detail } => {
if label.is_empty() || label.trim() != label {
return Err(KitError::invalid_input(
"progress notification label must be nonempty and unpadded",
));
}
if detail
.as_ref()
.is_some_and(|detail| detail.trim().is_empty())
{
return Err(KitError::invalid_input(
"progress notification detail must be absent or nonblank",
));
}
Ok(())
}
Self::Interaction {
title,
body,
response_schema,
..
} => {
if title.is_empty() || title.trim() != title {
return Err(KitError::invalid_input(
"interaction title must be nonempty and unpadded",
));
}
if body.trim().is_empty() {
return Err(KitError::invalid_input("interaction body must be nonblank"));
}
if response_schema
.as_ref()
.is_some_and(|schema| !schema.is_object())
{
return Err(KitError::invalid_input(
"interaction response schema must be a JSON object",
));
}
Ok(())
}
Self::Message { .. }
| Self::Process { .. }
| Self::Warning { .. }
| Self::Error { .. } => Ok(()),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum NotificationResolution {
Allow,
Deny {
reason: String,
},
Submit {
content: serde_json::Value,
},
Dismiss {
reason: String,
},
}
impl NotificationResolution {
pub(crate) fn validate(&self) -> Result<(), KitError> {
match self {
Self::Deny { reason } | Self::Dismiss { reason }
if reason.is_empty() || reason.trim() != reason =>
{
Err(KitError::invalid_input(
"notification resolution reason must be nonempty and unpadded",
))
}
_ => Ok(()),
}
}
}
pub struct ConversationNotificationReceiver {
inner: Pin<Box<dyn Stream<Item = Result<ConversationNotification, KitError>> + Send + 'static>>,
terminal: bool,
}
impl ConversationNotificationReceiver {
pub(crate) fn shared<S: StateStoreBackend>(inner: NotificationReceiver<S>) -> Self {
Self::from_stream(futures_util::stream::unfold(
inner,
|mut receiver| async move { receiver.recv().await.map(|item| (item, receiver)) },
))
}
pub(crate) fn custom<S: StateStoreBackend, D: DatabaseBackend>(
inner: CustomNotificationReceiver<S, D>,
) -> Self {
Self::from_stream(futures_util::stream::unfold(
inner,
|mut receiver| async move { receiver.recv().await.map(|item| (item, receiver)) },
))
}
pub fn from_stream<S>(stream: S) -> Self
where
S: Stream<Item = Result<ConversationNotification, KitError>> + Send + 'static,
{
Self {
inner: Box::pin(stream),
terminal: false,
}
}
pub async fn recv(&mut self) -> Option<Result<ConversationNotification, KitError>> {
if self.terminal {
return None;
}
let next = self.inner.next().await.map(|result| {
result.map_err(|error| {
error
.at_operation(crate::error::Operation::NotificationStream)
.in_phase(crate::error::ErrorPhase::Publication)
})
});
if next.is_none()
|| matches!(
&next, Some(Err(error)) if matches!(error.kind(),
crate::error::ErrorKind::Conflict | crate::error::ErrorKind::Closed |
crate::error::ErrorKind::Unsupported | crate::error::ErrorKind::NotYetImplemented |
crate::error::ErrorKind::Protocol | crate::error::ErrorKind::InvalidState)
)
{
self.terminal = true;
}
next
}
}