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                    let key = format!("lifecycle.{}.returned_error", lifecycle_event_slug(event));
77                    crate::notify!(
78                        error,
79                        location = Inline,
80                        stack = dedupe(key, 60_000),
81                        "lifecycle on {} returned error: {e}",
82                        lifecycle_event_slug(event)
83                    );
84                }
85                Err(e) => {
86                    let key = format!("lifecycle.{}.run_failed", lifecycle_event_slug(event));
87                    crate::notify!(
88                        error,
89                        location = Inline,
90                        stack = dedupe(key, 60_000),
91                        "lifecycle on {} failed to run: {e}",
92                        lifecycle_event_slug(event)
93                    );
94                }
95                Ok(_) => {}
96            }
97        }
98    }
99}
100
101impl Default for LifecycleRunner {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107fn lifecycle_event_slug(event: LifecycleEvent) -> &'static str {
108    match event {
109        LifecycleEvent::SessionStart => "session.start",
110        LifecycleEvent::SessionEnd => "session.end",
111        LifecycleEvent::TurnStart => "turn.start",
112        LifecycleEvent::TurnEnd => "turn.end",
113        LifecycleEvent::ContextCompact => "session.context_compact",
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use atman_dsl::parse::parse_file;
121
122    fn drain_lifecycle_from(src: &str) -> LifecycleRunner {
123        let file = parse_file(src).unwrap();
124        let mut r = LifecycleRunner::new();
125        r.absorb(&file);
126        r
127    }
128
129    #[test]
130    fn from_dir_picks_up_lifecycles_across_at_files() {
131        let dir = tempfile::tempdir().unwrap();
132        std::fs::write(
133            dir.path().join("a.at"),
134            "on session.start { }\non session.end { }\n",
135        )
136        .unwrap();
137        std::fs::write(dir.path().join("b.at"), "on turn.start { }\n").unwrap();
138        std::fs::write(dir.path().join("c.txt"), "on session.start { }\n").unwrap();
139
140        let runner = LifecycleRunner::from_dir(dir.path());
141        assert!(runner.has(LifecycleEvent::SessionStart));
142        assert!(runner.has(LifecycleEvent::SessionEnd));
143        assert!(runner.has(LifecycleEvent::TurnStart));
144        assert!(!runner.has(LifecycleEvent::TurnEnd));
145    }
146
147    fn build_executor_with_todos(dir: &Path) -> Executor {
148        let ex = Executor::new();
149        crate::tools::register_tier_zero(&ex.tools);
150        let todo = std::sync::Arc::new(crate::memory::TodoStore::at(dir));
151        let confession = std::sync::Arc::new(crate::memory::ConfessionStore::at(dir));
152        let goal = std::sync::Arc::new(crate::memory::GoalStore::at(dir));
153        let plan = std::sync::Arc::new(crate::memory::PlanStore::at(dir));
154        crate::tools::register_memory(&ex.tools, todo, confession, goal, plan);
155        ex
156    }
157
158    fn set_todo_stmt(where_: &str) -> String {
159        format!(
160            r#"memory.todo.set(
161                where: "{where_}",
162                why: "test",
163                how: "test",
164                expected_result: "test"
165            )"#
166        )
167    }
168
169    #[tokio::test]
170    async fn multiple_bodies_for_same_event_fire_in_declaration_order() {
171        let src = format!(
172            "on session.start {{ {} }}\non session.start {{ {} }}\n",
173            set_todo_stmt("first"),
174            set_todo_stmt("second"),
175        );
176        let runner = drain_lifecycle_from(&src);
177        let dir = tempfile::tempdir().unwrap();
178        let ex = build_executor_with_todos(dir.path());
179
180        runner.fire(&ex, LifecycleEvent::SessionStart).await;
181
182        let todos = std::fs::read_to_string(dir.path().join("todos.jsonl")).unwrap();
183        let lines: Vec<&str> = todos.lines().collect();
184        assert_eq!(lines.len(), 2, "todos: {todos}");
185        let first_idx = lines
186            .iter()
187            .position(|l| l.contains("\"first\""))
188            .expect("first missing");
189        let second_idx = lines
190            .iter()
191            .position(|l| l.contains("\"second\""))
192            .expect("second missing");
193        assert!(first_idx < second_idx, "wrong order: {lines:?}");
194    }
195
196    #[tokio::test]
197    async fn body_error_does_not_stop_later_bodies() {
198        let src = format!(
199            "on session.start {{ x = fs.read(@\"/no/such/path/definitely/not/real\") }}\n\
200             on session.start {{ {} }}\n",
201            set_todo_stmt("still_ran"),
202        );
203        let runner = drain_lifecycle_from(&src);
204        let dir = tempfile::tempdir().unwrap();
205        let ex = build_executor_with_todos(dir.path());
206
207        runner.fire(&ex, LifecycleEvent::SessionStart).await;
208        let todos = std::fs::read_to_string(dir.path().join("todos.jsonl")).unwrap();
209        assert!(todos.contains("still_ran"), "todos: {todos}");
210    }
211
212    #[tokio::test]
213    async fn fire_ignores_events_that_dont_match_declaration() {
214        let src = format!(
215            "on session.end {{ {} }}\n",
216            set_todo_stmt("session_end_only")
217        );
218        let runner = drain_lifecycle_from(&src);
219        let dir = tempfile::tempdir().unwrap();
220        let ex = build_executor_with_todos(dir.path());
221
222        runner.fire(&ex, LifecycleEvent::SessionStart).await;
223        assert!(!dir.path().join("todos.jsonl").exists());
224
225        runner.fire(&ex, LifecycleEvent::SessionEnd).await;
226        let todos = std::fs::read_to_string(dir.path().join("todos.jsonl")).unwrap();
227        assert!(todos.contains("session_end_only"), "todos: {todos}");
228    }
229}