Skip to main content

atman_runtime/
lifecycle.rs

1use std::path::Path;
2
3use atman_dsl::ast::{File, LifecycleDecl, LifecycleEvent};
4
5use crate::executor::Executor;
6use crate::value::Value;
7
8pub struct LifecycleRunner {
9    decls: Vec<LifecycleDecl>,
10}
11
12impl LifecycleRunner {
13    pub fn new() -> Self {
14        Self { decls: Vec::new() }
15    }
16
17    pub fn from_dir(dir: &Path) -> Self {
18        let mut runner = Self::new();
19        let Ok(entries) = std::fs::read_dir(dir) else {
20            return runner;
21        };
22        for entry in entries.flatten() {
23            let path = entry.path();
24            if path.extension().and_then(|s| s.to_str()) != Some("at") {
25                continue;
26            }
27            let Ok(source) = std::fs::read_to_string(&path) else {
28                continue;
29            };
30            let Ok(file) = atman_dsl::parse::parse_file(&source) else {
31                continue;
32            };
33            runner.absorb(&file);
34        }
35        runner
36    }
37
38    pub fn absorb(&mut self, file: &File) {
39        for decl in &file.lifecycles {
40            self.decls.push(decl.clone());
41        }
42    }
43
44    pub fn is_empty(&self) -> bool {
45        self.decls.iter().all(|d| d.body.is_empty())
46    }
47
48    pub fn has(&self, event: LifecycleEvent) -> bool {
49        self.decls.iter().any(|d| d.event == event)
50    }
51
52    pub async fn fire(&self, executor: &Executor, event: LifecycleEvent) {
53        for (idx, decl) in self.decls.iter().enumerate() {
54            if decl.event != event {
55                continue;
56            }
57            let flow_name = format!("__lifecycle_{}_{idx}", lifecycle_event_slug(event));
58            let flow = atman_dsl::ast::FlowDecl {
59                name: atman_dsl::ast::Ident {
60                    name: flow_name.clone(),
61                    span: decl.span,
62                },
63                params: Vec::new(),
64                ret: None,
65                contract: None,
66                body: decl.body.clone(),
67            };
68            let file = atman_dsl::ast::File {
69                flows: vec![flow],
70                routes: Vec::new(),
71                default_route: None,
72                lifecycles: Vec::new(),
73            };
74            match executor.run(&file, &flow_name, Vec::new()).await {
75                Ok(Value::Err(e)) => {
76                    eprintln!("[atman] on {} body error: {e}", lifecycle_event_slug(event));
77                }
78                Err(e) => {
79                    eprintln!("[atman] on {} body error: {e}", lifecycle_event_slug(event));
80                }
81                Ok(_) => {}
82            }
83        }
84    }
85}
86
87impl Default for LifecycleRunner {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93fn lifecycle_event_slug(event: LifecycleEvent) -> &'static str {
94    match event {
95        LifecycleEvent::SessionStart => "session.start",
96        LifecycleEvent::SessionEnd => "session.end",
97        LifecycleEvent::TurnStart => "turn.start",
98        LifecycleEvent::TurnEnd => "turn.end",
99        LifecycleEvent::ContextCompact => "session.context_compact",
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use atman_dsl::parse::parse_file;
107
108    fn drain_lifecycle_from(src: &str) -> LifecycleRunner {
109        let file = parse_file(src).unwrap();
110        let mut r = LifecycleRunner::new();
111        r.absorb(&file);
112        r
113    }
114
115    #[test]
116    fn from_dir_picks_up_lifecycles_across_at_files() {
117        let dir = tempfile::tempdir().unwrap();
118        std::fs::write(
119            dir.path().join("a.at"),
120            "on session.start { }\non session.end { }\n",
121        )
122        .unwrap();
123        std::fs::write(dir.path().join("b.at"), "on turn.start { }\n").unwrap();
124        std::fs::write(dir.path().join("c.txt"), "on session.start { }\n").unwrap();
125
126        let runner = LifecycleRunner::from_dir(dir.path());
127        assert!(runner.has(LifecycleEvent::SessionStart));
128        assert!(runner.has(LifecycleEvent::SessionEnd));
129        assert!(runner.has(LifecycleEvent::TurnStart));
130        assert!(!runner.has(LifecycleEvent::TurnEnd));
131    }
132
133    fn build_executor_with_todos(dir: &Path) -> Executor {
134        let mut ex = Executor::new();
135        crate::tools::register_tier_zero(&mut ex.tools);
136        let todo = std::sync::Arc::new(crate::memory::TodoStore::at(dir));
137        let confession = std::sync::Arc::new(crate::memory::ConfessionStore::at(dir));
138        let goal = std::sync::Arc::new(crate::memory::GoalStore::at(dir));
139        let plan = std::sync::Arc::new(crate::memory::PlanStore::at(dir));
140        crate::tools::register_memory(&mut ex.tools, todo, confession, goal, plan);
141        ex
142    }
143
144    fn set_todo_stmt(where_: &str) -> String {
145        format!(
146            r#"memory.todo.set(
147                where: "{where_}",
148                why: "test",
149                how: "test",
150                expected_result: "test"
151            )"#
152        )
153    }
154
155    #[tokio::test]
156    async fn multiple_bodies_for_same_event_fire_in_declaration_order() {
157        let src = format!(
158            "on session.start {{ {} }}\non session.start {{ {} }}\n",
159            set_todo_stmt("first"),
160            set_todo_stmt("second"),
161        );
162        let runner = drain_lifecycle_from(&src);
163        let dir = tempfile::tempdir().unwrap();
164        let ex = build_executor_with_todos(dir.path());
165
166        runner.fire(&ex, LifecycleEvent::SessionStart).await;
167
168        let todos = std::fs::read_to_string(dir.path().join("todos.jsonl")).unwrap();
169        let lines: Vec<&str> = todos.lines().collect();
170        assert_eq!(lines.len(), 2, "todos: {todos}");
171        let first_idx = lines
172            .iter()
173            .position(|l| l.contains("\"first\""))
174            .expect("first missing");
175        let second_idx = lines
176            .iter()
177            .position(|l| l.contains("\"second\""))
178            .expect("second missing");
179        assert!(first_idx < second_idx, "wrong order: {lines:?}");
180    }
181
182    #[tokio::test]
183    async fn body_error_does_not_stop_later_bodies() {
184        let src = format!(
185            "on session.start {{ x = fs.read(@\"/no/such/path/definitely/not/real\") }}\n\
186             on session.start {{ {} }}\n",
187            set_todo_stmt("still_ran"),
188        );
189        let runner = drain_lifecycle_from(&src);
190        let dir = tempfile::tempdir().unwrap();
191        let ex = build_executor_with_todos(dir.path());
192
193        runner.fire(&ex, LifecycleEvent::SessionStart).await;
194        let todos = std::fs::read_to_string(dir.path().join("todos.jsonl")).unwrap();
195        assert!(todos.contains("still_ran"), "todos: {todos}");
196    }
197
198    #[tokio::test]
199    async fn fire_ignores_events_that_dont_match_declaration() {
200        let src = format!(
201            "on session.end {{ {} }}\n",
202            set_todo_stmt("session_end_only")
203        );
204        let runner = drain_lifecycle_from(&src);
205        let dir = tempfile::tempdir().unwrap();
206        let ex = build_executor_with_todos(dir.path());
207
208        runner.fire(&ex, LifecycleEvent::SessionStart).await;
209        assert!(!dir.path().join("todos.jsonl").exists());
210
211        runner.fire(&ex, LifecycleEvent::SessionEnd).await;
212        let todos = std::fs::read_to_string(dir.path().join("todos.jsonl")).unwrap();
213        assert!(todos.contains("session_end_only"), "todos: {todos}");
214    }
215}