use anda_core::{BoxError, CancellationToken};
use core::{fmt, str::FromStr};
use parking_lot::RwLock;
use std::{any::Any, collections::HashMap, sync::Arc};
use structured_logger::unix_ms;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct PrefixedId {
pub prefix: String,
pub id: String,
}
impl fmt::Display for PrefixedId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.prefix, self.id)
}
}
impl FromStr for PrefixedId {
type Err = BoxError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let Some((prefix, id)) = s.split_once(':') else {
return Err(format!("Invalid PrefixedId format: {}", s).into());
};
if prefix.trim().is_empty() || id.trim().is_empty() {
return Err(format!("Prefix and ID cannot be empty: {}", s).into());
}
if prefix.trim() != prefix || id.trim() != id {
return Err(format!(
"Prefix and ID cannot have leading or trailing whitespace: {}",
s
)
.into());
}
Ok(Self {
prefix: prefix.to_string(),
id: id.to_string(),
})
}
}
#[derive(Clone)]
pub struct BackgroundHandle {
task_id: String,
token: CancellationToken,
created_at: u64,
data: Option<Arc<dyn Any + Send + Sync>>,
}
impl BackgroundHandle {
pub fn new(task_id: impl Into<String>, token: CancellationToken) -> Self {
Self {
task_id: task_id.into(),
token,
created_at: unix_ms(),
data: None,
}
}
pub fn with_data<T: Any + Send + Sync>(mut self, data: T) -> Self {
self.data = Some(Arc::new(data));
self
}
pub fn with_shared_data<T: Any + Send + Sync>(mut self, data: Arc<T>) -> Self {
let data: Arc<dyn Any + Send + Sync> = data;
self.data = Some(data);
self
}
pub fn data<T: Any + Send + Sync>(&self) -> Option<Arc<T>> {
self.data.clone()?.downcast::<T>().ok()
}
pub fn task_id(&self) -> &str {
&self.task_id
}
pub fn created_at(&self) -> u64 {
self.created_at
}
pub fn elapsed_ms(&self) -> u64 {
unix_ms().saturating_sub(self.created_at)
}
pub fn stop(&self) {
self.token.cancel();
}
pub fn is_stopped(&self) -> bool {
self.token.is_cancelled()
}
}
#[derive(Clone, Default)]
pub struct BackgroundTaskControls {
tasks: Arc<RwLock<HashMap<String, BackgroundHandle>>>,
}
impl BackgroundTaskControls {
pub fn new() -> Self {
Self::default()
}
pub fn register(&self, handle: BackgroundHandle) {
self.tasks
.write()
.insert(handle.task_id().to_string(), handle);
}
pub fn get(&self, task_id: &str) -> Option<BackgroundHandle> {
self.tasks.read().get(task_id).cloned()
}
pub fn get_data<T: Any + Send + Sync>(&self, task_id: &str) -> Option<Arc<T>> {
self.tasks.read().get(task_id)?.data::<T>()
}
pub fn handles(&self) -> Vec<BackgroundHandle> {
self.tasks.read().values().cloned().collect()
}
pub fn is_empty(&self) -> bool {
self.tasks.read().is_empty()
}
pub fn finish(&self, task_id: &str) -> Option<BackgroundHandle> {
self.tasks.write().remove(task_id)
}
pub fn finish_all(&self) -> Vec<BackgroundHandle> {
self.tasks
.write()
.drain()
.map(|(_, handle)| handle)
.collect()
}
pub fn stop_background_task(&self, task_id: &str) -> bool {
match self.tasks.read().get(task_id) {
Some(handle) => {
handle.stop();
true
}
None => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use parking_lot::Mutex;
#[test]
fn background_handle_cancels_only_its_token() {
let parent = CancellationToken::new();
let task_token = parent.child_token();
let handle = BackgroundHandle::new("shell:1", task_token.clone());
assert_eq!(handle.task_id(), "shell:1");
assert!(!handle.is_stopped());
assert!(!task_token.is_cancelled());
handle.stop();
assert!(handle.is_stopped());
assert!(task_token.is_cancelled());
assert!(!parent.is_cancelled());
handle.stop();
}
#[test]
fn background_handle_carries_payload_and_created_at() {
let handle =
BackgroundHandle::new("shell:1", CancellationToken::new()).with_data(Mutex::new(7u32));
assert!(handle.created_at() > 0);
let _ = handle.elapsed_ms();
let clone = handle.clone();
*handle.data::<Mutex<u32>>().unwrap().lock() = 42;
assert_eq!(*clone.data::<Mutex<u32>>().unwrap().lock(), 42);
assert!(handle.data::<Mutex<String>>().is_none());
let bare = BackgroundHandle::new("shell:2", CancellationToken::new());
assert!(bare.data::<Mutex<u32>>().is_none());
}
#[test]
fn background_task_controls_stop_finish_and_finish_all() {
let controls = BackgroundTaskControls::new();
let token_a = CancellationToken::new();
let token_b = CancellationToken::new();
controls.register(BackgroundHandle::new("a", token_a.clone()));
controls.register(BackgroundHandle::new("b", token_b.clone()));
assert!(controls.stop_background_task("a"));
assert!(token_a.is_cancelled());
assert!(controls.get("a").is_some());
assert!(controls.stop_background_task("a"));
assert!(!controls.stop_background_task("missing"));
assert_eq!(controls.finish("a").unwrap().task_id(), "a");
assert!(controls.get("a").is_none());
assert!(!controls.stop_background_task("a"));
controls.finish("b");
assert!(!token_b.is_cancelled());
assert!(!controls.stop_background_task("b"));
let token_c = CancellationToken::new();
controls.register(BackgroundHandle::new("c", token_c.clone()));
assert!(!controls.is_empty());
let drained = controls.finish_all();
assert_eq!(drained.len(), 1);
assert_eq!(drained[0].task_id(), "c");
assert!(!token_c.is_cancelled());
assert!(controls.is_empty());
}
#[test]
fn prefixed_id_display_and_parse_validate_shape() {
let id: PrefixedId = "agent:run-1".parse().unwrap();
assert_eq!(id.prefix, "agent");
assert_eq!(id.id, "run-1");
assert_eq!(id.to_string(), "agent:run-1");
assert!("missing_separator".parse::<PrefixedId>().is_err());
assert!(":empty_prefix".parse::<PrefixedId>().is_err());
assert!("empty_id:".parse::<PrefixedId>().is_err());
assert!(" agent:run".parse::<PrefixedId>().is_err());
assert!("agent:run ".parse::<PrefixedId>().is_err());
}
}