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 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}