tirea_contract/runtime/run/
lifecycle.rs1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use tirea_state::State;
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub struct StoppedReason {
8 pub code: String,
9 #[serde(skip_serializing_if = "Option::is_none")]
10 pub detail: Option<String>,
11}
12
13impl StoppedReason {
14 #[must_use]
15 pub fn new(code: impl Into<String>) -> Self {
16 Self {
17 code: code.into(),
18 detail: None,
19 }
20 }
21
22 #[must_use]
23 pub fn with_detail(code: impl Into<String>, detail: impl Into<String>) -> Self {
24 Self {
25 code: code.into(),
26 detail: Some(detail.into()),
27 }
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(tag = "type", content = "value", rename_all = "snake_case")]
34pub enum TerminationReason {
35 NaturalEnd,
37 #[serde(alias = "plugin_requested")]
39 BehaviorRequested,
40 Stopped(StoppedReason),
42 Cancelled,
44 Suspended,
46 Error(String),
48}
49
50impl TerminationReason {
51 #[must_use]
52 pub fn stopped(code: impl Into<String>) -> Self {
53 Self::Stopped(StoppedReason::new(code))
54 }
55
56 #[must_use]
57 pub fn stopped_with_detail(code: impl Into<String>, detail: impl Into<String>) -> Self {
58 Self::Stopped(StoppedReason::with_detail(code, detail))
59 }
60
61 pub fn to_run_status(&self) -> (RunStatus, Option<String>) {
63 match self {
64 Self::Suspended => (RunStatus::Waiting, None),
65 Self::NaturalEnd => (RunStatus::Done, Some("natural".to_string())),
66 Self::BehaviorRequested => (RunStatus::Done, Some("behavior_requested".to_string())),
67 Self::Cancelled => (RunStatus::Done, Some("cancelled".to_string())),
68 Self::Error(_) => (RunStatus::Done, Some("error".to_string())),
69 Self::Stopped(stopped) => (RunStatus::Done, Some(format!("stopped:{}", stopped.code))),
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "snake_case")]
77pub enum RunStatus {
78 #[default]
80 Running,
81 Waiting,
83 Done,
85}
86
87impl RunStatus {
88 pub const ASCII_STATE_MACHINE: &str = r#"start
90 |
91 v
92running -------> done
93 |
94 v
95waiting -------> done
96 |
97 +-----------> running"#;
98
99 pub fn is_terminal(self) -> bool {
101 matches!(self, RunStatus::Done)
102 }
103
104 pub fn can_transition_to(self, next: Self) -> bool {
106 if self == next {
107 return true;
108 }
109
110 match self {
111 RunStatus::Running => {
112 matches!(next, RunStatus::Waiting | RunStatus::Done)
113 }
114 RunStatus::Waiting => {
115 matches!(next, RunStatus::Running | RunStatus::Done)
116 }
117 RunStatus::Done => false,
118 }
119 }
120}
121
122#[derive(Debug, Clone, Default, Serialize, Deserialize, State, PartialEq, Eq)]
124#[tirea(path = "__run", action = "RunLifecycleAction", scope = "run")]
125pub struct RunLifecycleState {
126 #[serde(default)]
128 pub id: String,
129 #[serde(default)]
131 pub status: RunStatus,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub done_reason: Option<String>,
135 #[serde(default)]
137 pub updated_at: u64,
138}
139
140#[derive(Serialize, Deserialize)]
142pub enum RunLifecycleAction {
143 Set {
145 id: String,
146 status: RunStatus,
147 done_reason: Option<String>,
148 updated_at: u64,
149 },
150}
151
152impl RunLifecycleState {
153 fn reduce(&mut self, action: RunLifecycleAction) {
154 match action {
155 RunLifecycleAction::Set {
156 id,
157 status,
158 done_reason,
159 updated_at,
160 } => {
161 self.id = id;
162 self.status = status;
163 self.done_reason = done_reason;
164 self.updated_at = updated_at;
165 }
166 }
167 }
168}
169
170pub fn run_lifecycle_from_state(state: &Value) -> Option<RunLifecycleState> {
172 state
173 .get(RunLifecycleState::PATH)
174 .and_then(|v| RunLifecycleState::from_value(v).ok())
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use crate::runtime::state::{reduce_state_actions, AnyStateAction, ScopeContext};
181 use tirea_state::apply_patch;
182
183 #[test]
184 fn run_lifecycle_roundtrip_from_state() {
185 let state = serde_json::json!({
186 "__run": {
187 "id": "run_1",
188 "status": "running",
189 "updated_at": 42
190 }
191 });
192
193 let lifecycle = run_lifecycle_from_state(&state).expect("run lifecycle");
194 assert_eq!(lifecycle.id, "run_1");
195 assert_eq!(lifecycle.status, RunStatus::Running);
196 assert_eq!(lifecycle.done_reason, None);
197 assert_eq!(lifecycle.updated_at, 42);
198 }
199
200 #[test]
201 fn run_lifecycle_status_transitions_match_state_machine() {
202 assert!(RunStatus::Running.can_transition_to(RunStatus::Waiting));
203 assert!(RunStatus::Running.can_transition_to(RunStatus::Done));
204 assert!(RunStatus::Waiting.can_transition_to(RunStatus::Running));
205 assert!(RunStatus::Waiting.can_transition_to(RunStatus::Done));
206 assert!(RunStatus::Running.can_transition_to(RunStatus::Running));
207 }
208
209 #[test]
210 fn run_lifecycle_status_rejects_done_reopen_transitions() {
211 assert!(!RunStatus::Done.can_transition_to(RunStatus::Running));
212 assert!(!RunStatus::Done.can_transition_to(RunStatus::Waiting));
213 }
214
215 #[test]
216 fn termination_reason_to_run_status_mapping() {
217 let cases = vec![
218 (TerminationReason::Suspended, RunStatus::Waiting, None),
219 (
220 TerminationReason::NaturalEnd,
221 RunStatus::Done,
222 Some("natural"),
223 ),
224 (
225 TerminationReason::BehaviorRequested,
226 RunStatus::Done,
227 Some("behavior_requested"),
228 ),
229 (
230 TerminationReason::Cancelled,
231 RunStatus::Done,
232 Some("cancelled"),
233 ),
234 (
235 TerminationReason::Error("test error".to_string()),
236 RunStatus::Done,
237 Some("error"),
238 ),
239 (
240 TerminationReason::stopped("max_turns"),
241 RunStatus::Done,
242 Some("stopped:max_turns"),
243 ),
244 ];
245 for (reason, expected_status, expected_done) in cases {
246 let (status, done) = reason.to_run_status();
247 assert_eq!(status, expected_status, "status mismatch for {reason:?}");
248 assert_eq!(
249 done.as_deref(),
250 expected_done,
251 "done_reason mismatch for {reason:?}"
252 );
253 }
254 }
255
256 #[test]
257 fn run_lifecycle_ascii_state_machine_contains_all_states() {
258 let diagram = RunStatus::ASCII_STATE_MACHINE;
259 assert!(diagram.contains("running"));
260 assert!(diagram.contains("waiting"));
261 assert!(diagram.contains("done"));
262 assert!(diagram.contains("start"));
263 }
264
265 #[test]
266 fn run_lifecycle_state_action_reduces_into_run_envelope_patch() {
267 let base = serde_json::json!({});
268 let actions = vec![AnyStateAction::new::<RunLifecycleState>(
269 RunLifecycleAction::Set {
270 id: "run_42".to_string(),
271 status: RunStatus::Waiting,
272 done_reason: None,
273 updated_at: 99,
274 },
275 )];
276
277 let patches = reduce_state_actions(actions, &base, "agent_loop", &ScopeContext::run())
278 .expect("reduce");
279 assert_eq!(patches.len(), 1);
280
281 let merged = apply_patch(&base, patches[0].patch()).expect("apply");
282 assert_eq!(merged["__run"]["id"], serde_json::json!("run_42"));
283 assert_eq!(merged["__run"]["status"], serde_json::json!("waiting"));
284 assert!(merged["__run"]["done_reason"].is_null());
285 assert_eq!(merged["__run"]["updated_at"], serde_json::json!(99u64));
286 }
287}