akribes_sdk/runtime.rs
1//! SDK-facing typed mirror of the engine's `Runtime*` events.
2//!
3//! These five variants describe the lifecycle of a `runtime` block — Akribes's
4//! first-class construct for running AI-generated code inside a sandboxed
5//! container (Python, Bash, Node, Rust, Java). They flow alongside the
6//! wrapping task's `TaskStart` / `TaskEnd` so reducers that group by task
7//! continue to work.
8//!
9//! Wire shape uses the engine's standard tagged envelope
10//! `{"type": "<Variant>", "payload": {...}}` — same as `TaskStart`, `TaskEnd`,
11//! `ToolCallStart`. See `crates/akribes-core/src/event.rs` for the
12//! source-of-truth `EngineEvent` enum.
13//!
14//! ```json
15//! {"type": "RuntimeStart", "payload": {"task_name": "t", "runtime_name": "run_py", "language": "python"}}
16//! {"type": "RuntimeStdout", "payload": {"task_name": "t", "chunk": "hello\n"}}
17//! {"type": "RuntimeStderr", "payload": {"task_name": "t", "chunk": "warn\n"}}
18//! {"type": "RuntimeEnd", "payload": {"task_name": "t", "exit_code": 0, "duration_ms": 1234}}
19//! {"type": "RuntimeError", "payload": {"task_name": "t", "kind": "Timeout", "message": "..."}}
20//! ```
21//!
22//! `RuntimeError.kind` is a free-form string mirroring the engine's
23//! `RuntimeError` enum names (`NotConfigured`, `Timeout`, `SandboxUnavailable`,
24//! `OomKilled`, `Cancelled`, `Internal`). Consumers that want a typed match
25//! should use [`RuntimeErrorKind::from_wire`].
26
27use serde::{Deserialize, Serialize};
28
29/// `RuntimeStart` — emitted once when the engine dispatches a `runtime`
30/// block to the executor. Carries the wrapping task's name, the
31/// runtime block's declared name, and the language tag.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct RuntimeStartPayload {
34 /// Name of the task that wraps this runtime call (matches the
35 /// surrounding `TaskStart` / `TaskEnd` events).
36 pub task_name: String,
37 /// Name of the `runtime` block as declared in the source.
38 pub runtime_name: String,
39 /// Language tag — `"python" | "bash" | "node" | "rust" | "java"`.
40 /// Free-form string on the wire so a future language gets a new value
41 /// without an SDK release.
42 pub language: String,
43}
44
45/// `RuntimeStdout` — one chunk of stdout from the running container.
46/// Many of these may fire per invocation; consumers should accumulate.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48pub struct RuntimeStdoutPayload {
49 pub task_name: String,
50 /// Raw stdout bytes decoded as UTF-8 (invalid bytes replaced lossily).
51 pub chunk: String,
52}
53
54/// `RuntimeStderr` — one chunk of stderr from the running container.
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56pub struct RuntimeStderrPayload {
57 pub task_name: String,
58 pub chunk: String,
59}
60
61/// `RuntimeEnd` — emitted exactly once when the runtime invocation
62/// finished successfully (the executor returned an `ExecResult`).
63/// `exit_code == 0` is the conventional success signal but the engine
64/// surfaces non-zero codes here too — only true infrastructure failures
65/// (timeout, OOM, sandbox unreachable) emit [`RuntimeErrorPayload`] instead.
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct RuntimeEndPayload {
68 pub task_name: String,
69 pub exit_code: i32,
70 pub duration_ms: u64,
71}
72
73/// `RuntimeError` — emitted instead of `RuntimeEnd` when the runtime
74/// invocation could not complete (timeout, OOM, sandbox unavailable,
75/// configuration missing, …). `kind` is a stable string tag mirroring
76/// the engine's `RuntimeError` enum; `message` is human-readable.
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78pub struct RuntimeErrorPayload {
79 pub task_name: String,
80 /// One of `"NotConfigured" | "Timeout" | "SandboxUnavailable" |
81 /// "OomKilled" | "Cancelled" | "Internal"`. Other strings are
82 /// forward-compatible future kinds; see [`RuntimeErrorKind`].
83 pub kind: String,
84 pub message: String,
85}
86
87/// Typed mirror of the engine's `RuntimeError` enum for the
88/// `kind` field on [`RuntimeErrorPayload`]. Use [`RuntimeErrorKind::from_wire`]
89/// to dispatch; unknown strings surface as [`RuntimeErrorKind::Unknown`] so
90/// the SDK stays forward-compatible.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum RuntimeErrorKind {
93 /// `AKRIBES_SANDBOX_URL` / `_TOKEN` not set on the server.
94 NotConfigured,
95 /// Execution exceeded the runtime block's `timeout_secs`.
96 Timeout,
97 /// The sandbox service was unreachable / dropped the connection.
98 SandboxUnavailable,
99 /// Container exceeded its `memory_mb` cap (OOM killer fired).
100 OomKilled,
101 /// User (or engine cancellation token) terminated the call before
102 /// it produced an exit code. **Terminal** — retry policies should
103 /// NOT auto-retry on this kind; treat it the same as a `Skip`
104 /// (propagate the cancel upward).
105 Cancelled,
106 /// Catch-all for other failures (sandbox 5xx, unknown wire kind, …).
107 Internal,
108 /// Forward-compat: the engine emitted a `kind` string this SDK
109 /// release does not know about. Read the raw wire `kind` from the
110 /// surrounding [`RuntimeErrorPayload::kind`] field.
111 Unknown,
112}
113
114impl RuntimeErrorKind {
115 /// Map the wire string to a typed variant. Recognises the six
116 /// canonical engine kinds; anything else returns [`Self::Unknown`].
117 pub fn from_wire(s: &str) -> Self {
118 match s {
119 "NotConfigured" => Self::NotConfigured,
120 "Timeout" => Self::Timeout,
121 "SandboxUnavailable" => Self::SandboxUnavailable,
122 "OomKilled" => Self::OomKilled,
123 "Cancelled" => Self::Cancelled,
124 "Internal" => Self::Internal,
125 _ => Self::Unknown,
126 }
127 }
128
129 /// Emit the stable wire-form string for this kind, mirroring the
130 /// engine's [`as_wire_str`]. Returns `None` for [`Self::Unknown`]
131 /// because a forward-compat unknown tag has no canonical wire form
132 /// to round-trip through.
133 ///
134 /// [`as_wire_str`]: ../../akribes_core/code_exec/enum.RuntimeError.html#method.as_wire_str
135 pub fn to_wire(self) -> Option<&'static str> {
136 match self {
137 Self::NotConfigured => Some("NotConfigured"),
138 Self::Timeout => Some("Timeout"),
139 Self::SandboxUnavailable => Some("SandboxUnavailable"),
140 Self::OomKilled => Some("OomKilled"),
141 Self::Cancelled => Some("Cancelled"),
142 Self::Internal => Some("Internal"),
143 Self::Unknown => None,
144 }
145 }
146}
147
148/// Tagged-envelope decoder for the five `Runtime*` events. Matches the
149/// engine's `#[serde(tag = "type", content = "payload")]` shape so a raw
150/// JSON envelope decodes cleanly. The SDK uses this for the JSON-bypass
151/// path in [`crate::events::WorkflowEvent::from_envelope_json`].
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(tag = "type", content = "payload")]
154pub enum RuntimeEvent {
155 RuntimeStart(RuntimeStartPayload),
156 RuntimeStdout(RuntimeStdoutPayload),
157 RuntimeStderr(RuntimeStderrPayload),
158 RuntimeEnd(RuntimeEndPayload),
159 RuntimeError(RuntimeErrorPayload),
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use serde_json::json;
166
167 #[test]
168 fn runtime_start_roundtrips() {
169 let evt = RuntimeEvent::RuntimeStart(RuntimeStartPayload {
170 task_name: "analyse".into(),
171 runtime_name: "run_python".into(),
172 language: "python".into(),
173 });
174 let wire = serde_json::to_value(&evt).unwrap();
175 assert_eq!(wire["type"], "RuntimeStart");
176 assert_eq!(wire["payload"]["task_name"], "analyse");
177 assert_eq!(wire["payload"]["runtime_name"], "run_python");
178 assert_eq!(wire["payload"]["language"], "python");
179 let back: RuntimeEvent = serde_json::from_value(wire).unwrap();
180 assert_eq!(back, evt);
181 }
182
183 #[test]
184 fn runtime_stdout_roundtrips() {
185 let wire = json!({
186 "type": "RuntimeStdout",
187 "payload": {"task_name": "t", "chunk": "hello\n"},
188 });
189 let evt: RuntimeEvent = serde_json::from_value(wire.clone()).unwrap();
190 match &evt {
191 RuntimeEvent::RuntimeStdout(p) => {
192 assert_eq!(p.task_name, "t");
193 assert_eq!(p.chunk, "hello\n");
194 }
195 other => panic!("expected RuntimeStdout, got {other:?}"),
196 }
197 assert_eq!(serde_json::to_value(&evt).unwrap(), wire);
198 }
199
200 #[test]
201 fn runtime_stderr_roundtrips() {
202 let evt = RuntimeEvent::RuntimeStderr(RuntimeStderrPayload {
203 task_name: "t".into(),
204 chunk: "warn: deprecated\n".into(),
205 });
206 let wire = serde_json::to_value(&evt).unwrap();
207 assert_eq!(wire["type"], "RuntimeStderr");
208 let back: RuntimeEvent = serde_json::from_value(wire).unwrap();
209 assert_eq!(back, evt);
210 }
211
212 #[test]
213 fn runtime_end_roundtrips() {
214 let evt = RuntimeEvent::RuntimeEnd(RuntimeEndPayload {
215 task_name: "t".into(),
216 exit_code: 0,
217 duration_ms: 1234,
218 });
219 let wire = serde_json::to_value(&evt).unwrap();
220 assert_eq!(wire["type"], "RuntimeEnd");
221 assert_eq!(wire["payload"]["exit_code"], 0);
222 assert_eq!(wire["payload"]["duration_ms"], 1234);
223 let back: RuntimeEvent = serde_json::from_value(wire).unwrap();
224 assert_eq!(back, evt);
225 }
226
227 #[test]
228 fn runtime_end_negative_exit_code() {
229 // `exit_code` is `i32` so signals (negative codes on Unix when
230 // reported as such) round-trip cleanly.
231 let wire = json!({
232 "type": "RuntimeEnd",
233 "payload": {"task_name": "t", "exit_code": -9, "duration_ms": 50},
234 });
235 let evt: RuntimeEvent = serde_json::from_value(wire).unwrap();
236 match evt {
237 RuntimeEvent::RuntimeEnd(p) => assert_eq!(p.exit_code, -9),
238 _ => panic!("expected RuntimeEnd"),
239 }
240 }
241
242 #[test]
243 fn runtime_error_roundtrips() {
244 let evt = RuntimeEvent::RuntimeError(RuntimeErrorPayload {
245 task_name: "t".into(),
246 kind: "Timeout".into(),
247 message: "exceeded 30s budget".into(),
248 });
249 let wire = serde_json::to_value(&evt).unwrap();
250 assert_eq!(wire["type"], "RuntimeError");
251 let back: RuntimeEvent = serde_json::from_value(wire).unwrap();
252 assert_eq!(back, evt);
253 }
254
255 #[test]
256 fn runtime_error_kind_maps_known_variants() {
257 assert_eq!(
258 RuntimeErrorKind::from_wire("NotConfigured"),
259 RuntimeErrorKind::NotConfigured
260 );
261 assert_eq!(
262 RuntimeErrorKind::from_wire("Timeout"),
263 RuntimeErrorKind::Timeout
264 );
265 assert_eq!(
266 RuntimeErrorKind::from_wire("SandboxUnavailable"),
267 RuntimeErrorKind::SandboxUnavailable
268 );
269 assert_eq!(
270 RuntimeErrorKind::from_wire("OomKilled"),
271 RuntimeErrorKind::OomKilled
272 );
273 assert_eq!(
274 RuntimeErrorKind::from_wire("Cancelled"),
275 RuntimeErrorKind::Cancelled
276 );
277 assert_eq!(
278 RuntimeErrorKind::from_wire("Internal"),
279 RuntimeErrorKind::Internal
280 );
281 }
282
283 #[test]
284 fn runtime_error_kind_to_wire_round_trips() {
285 // Every concrete kind round-trips through wire → typed → wire so
286 // an SDK consumer that decodes-then-re-encodes (e.g. a UI proxy)
287 // can rely on byte-stability.
288 for s in [
289 "NotConfigured",
290 "Timeout",
291 "SandboxUnavailable",
292 "OomKilled",
293 "Cancelled",
294 "Internal",
295 ] {
296 let kind = RuntimeErrorKind::from_wire(s);
297 assert_eq!(kind.to_wire(), Some(s), "wire round-trip for {s}");
298 }
299 // Unknown has no canonical wire form; encoding back returns None
300 // so the caller can decide what to do (e.g. carry the raw string
301 // from the surrounding RuntimeErrorPayload).
302 assert_eq!(RuntimeErrorKind::Unknown.to_wire(), None);
303 }
304
305 #[test]
306 fn runtime_error_kind_unknown_falls_through() {
307 assert_eq!(
308 RuntimeErrorKind::from_wire("FutureKindFromNewerEngine"),
309 RuntimeErrorKind::Unknown
310 );
311 assert_eq!(RuntimeErrorKind::from_wire(""), RuntimeErrorKind::Unknown);
312 }
313
314 #[test]
315 fn unknown_runtime_type_fails_decode() {
316 // The runtime decoder only knows the 5 canonical types. A future
317 // RuntimeFoo event (or a non-runtime envelope) must FAIL to
318 // decode here so callers fall back to the EngineEvent path.
319 let wire = json!({
320 "type": "RuntimeFoo",
321 "payload": {"task_name": "t"},
322 });
323 assert!(serde_json::from_value::<RuntimeEvent>(wire).is_err());
324 let other = json!({
325 "type": "TaskStart",
326 "payload": ["t", null],
327 });
328 assert!(serde_json::from_value::<RuntimeEvent>(other).is_err());
329 }
330}