team_cron_registry.rs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. //! In-memory registries for Team and Cron lifecycle management.
  2. //!
  3. //! Provides TeamCreate/Delete and CronCreate/Delete/List runtime backing
  4. //! to replace the stub implementations in the tools crate.
  5. use std::collections::HashMap;
  6. use std::sync::{Arc, Mutex};
  7. use std::time::{SystemTime, UNIX_EPOCH};
  8. use serde::{Deserialize, Serialize};
  9. fn now_secs() -> u64 {
  10. SystemTime::now()
  11. .duration_since(UNIX_EPOCH)
  12. .unwrap_or_default()
  13. .as_secs()
  14. }
  15. // ─────────────────────────────────────────────
  16. // Team registry
  17. // ─────────────────────────────────────────────
  18. /// A team groups multiple tasks for parallel execution.
  19. #[derive(Debug, Clone, Serialize, Deserialize)]
  20. pub struct Team {
  21. pub team_id: String,
  22. pub name: String,
  23. pub task_ids: Vec<String>,
  24. pub status: TeamStatus,
  25. pub created_at: u64,
  26. pub updated_at: u64,
  27. }
  28. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
  29. #[serde(rename_all = "snake_case")]
  30. pub enum TeamStatus {
  31. Created,
  32. Running,
  33. Completed,
  34. Deleted,
  35. }
  36. impl std::fmt::Display for TeamStatus {
  37. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  38. match self {
  39. Self::Created => write!(f, "created"),
  40. Self::Running => write!(f, "running"),
  41. Self::Completed => write!(f, "completed"),
  42. Self::Deleted => write!(f, "deleted"),
  43. }
  44. }
  45. }
  46. /// Thread-safe team registry.
  47. #[derive(Debug, Clone, Default)]
  48. pub struct TeamRegistry {
  49. inner: Arc<Mutex<TeamInner>>,
  50. }
  51. #[derive(Debug, Default)]
  52. struct TeamInner {
  53. teams: HashMap<String, Team>,
  54. counter: u64,
  55. }
  56. impl TeamRegistry {
  57. #[must_use]
  58. pub fn new() -> Self {
  59. Self::default()
  60. }
  61. /// Create a new team with the given name and task IDs.
  62. pub fn create(&self, name: &str, task_ids: Vec<String>) -> Team {
  63. let mut inner = self.inner.lock().expect("team registry lock poisoned");
  64. inner.counter += 1;
  65. let ts = now_secs();
  66. let team_id = format!("team_{:08x}_{}", ts, inner.counter);
  67. let team = Team {
  68. team_id: team_id.clone(),
  69. name: name.to_owned(),
  70. task_ids,
  71. status: TeamStatus::Created,
  72. created_at: ts,
  73. updated_at: ts,
  74. };
  75. inner.teams.insert(team_id, team.clone());
  76. team
  77. }
  78. /// Get a team by ID.
  79. pub fn get(&self, team_id: &str) -> Option<Team> {
  80. let inner = self.inner.lock().expect("team registry lock poisoned");
  81. inner.teams.get(team_id).cloned()
  82. }
  83. /// List all teams.
  84. pub fn list(&self) -> Vec<Team> {
  85. let inner = self.inner.lock().expect("team registry lock poisoned");
  86. inner.teams.values().cloned().collect()
  87. }
  88. /// Delete a team.
  89. pub fn delete(&self, team_id: &str) -> Result<Team, String> {
  90. let mut inner = self.inner.lock().expect("team registry lock poisoned");
  91. let team = inner
  92. .teams
  93. .get_mut(team_id)
  94. .ok_or_else(|| format!("team not found: {team_id}"))?;
  95. team.status = TeamStatus::Deleted;
  96. team.updated_at = now_secs();
  97. Ok(team.clone())
  98. }
  99. /// Remove a team entirely from the registry.
  100. pub fn remove(&self, team_id: &str) -> Option<Team> {
  101. let mut inner = self.inner.lock().expect("team registry lock poisoned");
  102. inner.teams.remove(team_id)
  103. }
  104. #[must_use]
  105. pub fn len(&self) -> usize {
  106. let inner = self.inner.lock().expect("team registry lock poisoned");
  107. inner.teams.len()
  108. }
  109. #[must_use]
  110. pub fn is_empty(&self) -> bool {
  111. self.len() == 0
  112. }
  113. }
  114. // ─────────────────────────────────────────────
  115. // Cron registry
  116. // ─────────────────────────────────────────────
  117. /// A cron entry schedules a prompt to run on a recurring schedule.
  118. #[derive(Debug, Clone, Serialize, Deserialize)]
  119. pub struct CronEntry {
  120. pub cron_id: String,
  121. pub schedule: String,
  122. pub prompt: String,
  123. pub description: Option<String>,
  124. pub enabled: bool,
  125. pub created_at: u64,
  126. pub updated_at: u64,
  127. pub last_run_at: Option<u64>,
  128. pub run_count: u64,
  129. }
  130. /// Thread-safe cron registry.
  131. #[derive(Debug, Clone, Default)]
  132. pub struct CronRegistry {
  133. inner: Arc<Mutex<CronInner>>,
  134. }
  135. #[derive(Debug, Default)]
  136. struct CronInner {
  137. entries: HashMap<String, CronEntry>,
  138. counter: u64,
  139. }
  140. impl CronRegistry {
  141. #[must_use]
  142. pub fn new() -> Self {
  143. Self::default()
  144. }
  145. /// Create a new cron entry.
  146. pub fn create(&self, schedule: &str, prompt: &str, description: Option<&str>) -> CronEntry {
  147. let mut inner = self.inner.lock().expect("cron registry lock poisoned");
  148. inner.counter += 1;
  149. let ts = now_secs();
  150. let cron_id = format!("cron_{:08x}_{}", ts, inner.counter);
  151. let entry = CronEntry {
  152. cron_id: cron_id.clone(),
  153. schedule: schedule.to_owned(),
  154. prompt: prompt.to_owned(),
  155. description: description.map(str::to_owned),
  156. enabled: true,
  157. created_at: ts,
  158. updated_at: ts,
  159. last_run_at: None,
  160. run_count: 0,
  161. };
  162. inner.entries.insert(cron_id, entry.clone());
  163. entry
  164. }
  165. /// Get a cron entry by ID.
  166. pub fn get(&self, cron_id: &str) -> Option<CronEntry> {
  167. let inner = self.inner.lock().expect("cron registry lock poisoned");
  168. inner.entries.get(cron_id).cloned()
  169. }
  170. /// List all cron entries, optionally filtered to enabled only.
  171. pub fn list(&self, enabled_only: bool) -> Vec<CronEntry> {
  172. let inner = self.inner.lock().expect("cron registry lock poisoned");
  173. inner
  174. .entries
  175. .values()
  176. .filter(|e| !enabled_only || e.enabled)
  177. .cloned()
  178. .collect()
  179. }
  180. /// Delete (remove) a cron entry.
  181. pub fn delete(&self, cron_id: &str) -> Result<CronEntry, String> {
  182. let mut inner = self.inner.lock().expect("cron registry lock poisoned");
  183. inner
  184. .entries
  185. .remove(cron_id)
  186. .ok_or_else(|| format!("cron not found: {cron_id}"))
  187. }
  188. /// Disable a cron entry without removing it.
  189. pub fn disable(&self, cron_id: &str) -> Result<(), String> {
  190. let mut inner = self.inner.lock().expect("cron registry lock poisoned");
  191. let entry = inner
  192. .entries
  193. .get_mut(cron_id)
  194. .ok_or_else(|| format!("cron not found: {cron_id}"))?;
  195. entry.enabled = false;
  196. entry.updated_at = now_secs();
  197. Ok(())
  198. }
  199. /// Record a cron run.
  200. pub fn record_run(&self, cron_id: &str) -> Result<(), String> {
  201. let mut inner = self.inner.lock().expect("cron registry lock poisoned");
  202. let entry = inner
  203. .entries
  204. .get_mut(cron_id)
  205. .ok_or_else(|| format!("cron not found: {cron_id}"))?;
  206. entry.last_run_at = Some(now_secs());
  207. entry.run_count += 1;
  208. entry.updated_at = now_secs();
  209. Ok(())
  210. }
  211. #[must_use]
  212. pub fn len(&self) -> usize {
  213. let inner = self.inner.lock().expect("cron registry lock poisoned");
  214. inner.entries.len()
  215. }
  216. #[must_use]
  217. pub fn is_empty(&self) -> bool {
  218. self.len() == 0
  219. }
  220. }
  221. #[cfg(test)]
  222. mod tests {
  223. use super::*;
  224. // ── Team tests ──────────────────────────────────────
  225. #[test]
  226. fn creates_and_retrieves_team() {
  227. let registry = TeamRegistry::new();
  228. let team = registry.create("Alpha Squad", vec!["task_001".into(), "task_002".into()]);
  229. assert_eq!(team.name, "Alpha Squad");
  230. assert_eq!(team.task_ids.len(), 2);
  231. assert_eq!(team.status, TeamStatus::Created);
  232. let fetched = registry.get(&team.team_id).expect("team should exist");
  233. assert_eq!(fetched.team_id, team.team_id);
  234. }
  235. #[test]
  236. fn lists_and_deletes_teams() {
  237. let registry = TeamRegistry::new();
  238. let t1 = registry.create("Team A", vec![]);
  239. let t2 = registry.create("Team B", vec![]);
  240. let all = registry.list();
  241. assert_eq!(all.len(), 2);
  242. let deleted = registry.delete(&t1.team_id).expect("delete should succeed");
  243. assert_eq!(deleted.status, TeamStatus::Deleted);
  244. // Team is still listable (soft delete)
  245. let still_there = registry.get(&t1.team_id).unwrap();
  246. assert_eq!(still_there.status, TeamStatus::Deleted);
  247. // Hard remove
  248. registry.remove(&t2.team_id);
  249. assert_eq!(registry.len(), 1);
  250. }
  251. #[test]
  252. fn rejects_missing_team_operations() {
  253. let registry = TeamRegistry::new();
  254. assert!(registry.delete("nonexistent").is_err());
  255. assert!(registry.get("nonexistent").is_none());
  256. }
  257. // ── Cron tests ──────────────────────────────────────
  258. #[test]
  259. fn creates_and_retrieves_cron() {
  260. let registry = CronRegistry::new();
  261. let entry = registry.create("0 * * * *", "Check status", Some("hourly check"));
  262. assert_eq!(entry.schedule, "0 * * * *");
  263. assert_eq!(entry.prompt, "Check status");
  264. assert!(entry.enabled);
  265. assert_eq!(entry.run_count, 0);
  266. assert!(entry.last_run_at.is_none());
  267. let fetched = registry.get(&entry.cron_id).expect("cron should exist");
  268. assert_eq!(fetched.cron_id, entry.cron_id);
  269. }
  270. #[test]
  271. fn lists_with_enabled_filter() {
  272. let registry = CronRegistry::new();
  273. let c1 = registry.create("* * * * *", "Task 1", None);
  274. let c2 = registry.create("0 * * * *", "Task 2", None);
  275. registry
  276. .disable(&c1.cron_id)
  277. .expect("disable should succeed");
  278. let all = registry.list(false);
  279. assert_eq!(all.len(), 2);
  280. let enabled_only = registry.list(true);
  281. assert_eq!(enabled_only.len(), 1);
  282. assert_eq!(enabled_only[0].cron_id, c2.cron_id);
  283. }
  284. #[test]
  285. fn deletes_cron_entry() {
  286. let registry = CronRegistry::new();
  287. let entry = registry.create("* * * * *", "To delete", None);
  288. let deleted = registry
  289. .delete(&entry.cron_id)
  290. .expect("delete should succeed");
  291. assert_eq!(deleted.cron_id, entry.cron_id);
  292. assert!(registry.get(&entry.cron_id).is_none());
  293. assert!(registry.is_empty());
  294. }
  295. #[test]
  296. fn records_cron_runs() {
  297. let registry = CronRegistry::new();
  298. let entry = registry.create("*/5 * * * *", "Recurring", None);
  299. registry.record_run(&entry.cron_id).unwrap();
  300. registry.record_run(&entry.cron_id).unwrap();
  301. let fetched = registry.get(&entry.cron_id).unwrap();
  302. assert_eq!(fetched.run_count, 2);
  303. assert!(fetched.last_run_at.is_some());
  304. }
  305. #[test]
  306. fn rejects_missing_cron_operations() {
  307. let registry = CronRegistry::new();
  308. assert!(registry.delete("nonexistent").is_err());
  309. assert!(registry.disable("nonexistent").is_err());
  310. assert!(registry.record_run("nonexistent").is_err());
  311. assert!(registry.get("nonexistent").is_none());
  312. }
  313. }