atman_runtime/
lifecycle.rs1use 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}