Skip to main content

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}