Skip to content
This repository was archived by the owner on Apr 8, 2026. It is now read-only.

Commit 49653fe

Browse files
author
Jobdori
committed
Merge jobdori/team-cron-runtime: TeamRegistry + CronRegistry wired into tool dispatch
2 parents d994be6 + c486ca6 commit 49653fe

File tree

3 files changed

+441
-37
lines changed

3 files changed

+441
-37
lines changed

rust/crates/runtime/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub mod sandbox;
1717
mod session;
1818
mod sse;
1919
pub mod task_registry;
20+
pub mod team_cron_registry;
2021
mod usage;
2122

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

0 commit comments

Comments
 (0)