Skip to main content

a3s_flow/
runtime.rs

1use async_trait::async_trait;
2use serde::de::DeserializeOwned;
3use serde::{Deserialize, Serialize};
4#[cfg(feature = "native-ts")]
5use sha2::{Digest, Sha256};
6#[cfg(feature = "native-ts")]
7use std::path::Path;
8use std::path::PathBuf;
9
10#[cfg(feature = "native-ts")]
11use tokio::io::AsyncWriteExt;
12#[cfg(feature = "native-ts")]
13use tokio::process::Command;
14
15use crate::context::WorkflowContext;
16use crate::error::{FlowError, Result};
17#[cfg(feature = "native-ts")]
18use crate::model::RuntimeKind;
19use crate::model::{FlowEventEnvelope, JsonValue, RuntimeCommand, WorkflowSpec};
20#[cfg(feature = "native-ts")]
21use crate::protocol::{
22    NativeRuntimeKind, NativeRuntimeRequest, NativeRuntimeResponse, NATIVE_RUNTIME_PROTOCOL,
23};
24
25/// Workflow replay request passed to a runtime implementation.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct WorkflowInvocation {
28    pub run_id: String,
29    pub spec: WorkflowSpec,
30    pub input: JsonValue,
31    pub history: Vec<FlowEventEnvelope>,
32}
33
34impl WorkflowInvocation {
35    /// Build a deterministic helper view over this workflow invocation.
36    pub fn context(&self) -> WorkflowContext<'_> {
37        WorkflowContext::new(self)
38    }
39
40    /// Decode the workflow input into a host-defined serde type.
41    pub fn input_as<T>(&self) -> Result<T>
42    where
43        T: DeserializeOwned,
44    {
45        serde_json::from_value(self.input.clone()).map_err(FlowError::from)
46    }
47}
48
49/// Step execution request passed to a runtime implementation.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct StepInvocation {
52    pub run_id: String,
53    pub step_id: String,
54    pub step_name: String,
55    pub input: JsonValue,
56    pub history: Vec<FlowEventEnvelope>,
57}
58
59impl StepInvocation {
60    /// Decode the step input into a host-defined serde type.
61    pub fn input_as<T>(&self) -> Result<T>
62    where
63        T: DeserializeOwned,
64    {
65        serde_json::from_value(self.input.clone()).map_err(FlowError::from)
66    }
67}
68
69/// Runtime boundary for workflow code and side-effecting steps.
70#[async_trait]
71pub trait FlowRuntime: Send + Sync {
72    /// Replay the deterministic workflow function and return the next command.
73    async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand>;
74
75    /// Execute one side-effecting step. The engine persists success/failure.
76    async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue>;
77}
78
79/// Configuration for the native TypeScript runtime adapter.
80#[derive(Debug, Clone)]
81pub struct NativeTsRuntimeConfig {
82    pub compiler_binary: PathBuf,
83    pub cache_dir: PathBuf,
84    pub working_dir: PathBuf,
85}
86
87impl NativeTsRuntimeConfig {
88    pub fn new(
89        compiler_binary: impl Into<PathBuf>,
90        cache_dir: impl Into<PathBuf>,
91        working_dir: impl Into<PathBuf>,
92    ) -> Self {
93        Self {
94            compiler_binary: compiler_binary.into(),
95            cache_dir: cache_dir.into(),
96            working_dir: working_dir.into(),
97        }
98    }
99}
100
101impl Default for NativeTsRuntimeConfig {
102    fn default() -> Self {
103        Self {
104            compiler_binary: PathBuf::from("a3s-flow-native-compiler"),
105            cache_dir: PathBuf::from(".a3s-flow/native-ts"),
106            working_dir: PathBuf::from("."),
107        }
108    }
109}
110
111/// Runtime that compiles TypeScript to a native executable and speaks JSON over
112/// stdin/stdout with that executable.
113#[derive(Debug, Clone)]
114pub struct NativeTsRuntime {
115    config: NativeTsRuntimeConfig,
116}
117
118#[cfg(feature = "native-ts")]
119#[derive(Debug, Clone)]
120struct NativeArtifact {
121    entrypoint: PathBuf,
122    binary: PathBuf,
123    source_hash: String,
124}
125
126/// Result of validating and compiling a native TypeScript workflow source.
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128pub struct NativeTsRuntimePreflight {
129    /// Resolved workflow source entrypoint used by the compiler.
130    pub entrypoint: PathBuf,
131    /// Resolved native artifact path that will be invoked by the runtime.
132    pub artifact: PathBuf,
133    /// Stable hash of the workflow source and runtime identity fields.
134    pub source_hash: String,
135    /// True when the existing artifact cache entry was reused.
136    pub cache_hit: bool,
137}
138
139impl NativeTsRuntime {
140    pub fn new(config: NativeTsRuntimeConfig) -> Self {
141        Self { config }
142    }
143
144    pub fn config(&self) -> &NativeTsRuntimeConfig {
145        &self.config
146    }
147
148    #[cfg(feature = "native-ts")]
149    pub async fn preflight(&self, spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
150        self.compile_if_needed(spec).await
151    }
152
153    #[cfg(not(feature = "native-ts"))]
154    pub async fn preflight(&self, _spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
155        Err(FlowError::Runtime(
156            "native-ts feature is disabled for NativeTsRuntime".to_string(),
157        ))
158    }
159
160    #[cfg(feature = "native-ts")]
161    async fn artifact_for(&self, spec: &WorkflowSpec) -> Result<NativeArtifact> {
162        validate_native_ts_spec(spec)?;
163        let entrypoint = resolve_against(&self.config.working_dir, &spec.runtime.entrypoint);
164        let source = tokio::fs::read(&entrypoint).await?;
165        let source_hash = stable_hash([
166            b"source".as_slice(),
167            spec.name.as_bytes(),
168            spec.version.as_bytes(),
169            spec.runtime.entrypoint.as_bytes(),
170            spec.runtime.export_name.as_bytes(),
171            &source,
172        ]);
173        let name = format!("{}-{source_hash}", sanitize_filename(&spec.name));
174        Ok(NativeArtifact {
175            entrypoint,
176            binary: self.config.cache_dir.join(name),
177            source_hash,
178        })
179    }
180
181    #[cfg(feature = "native-ts")]
182    async fn compile_if_needed(&self, spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
183        let artifact = self.artifact_for(spec).await?;
184        if tokio::fs::metadata(&artifact.binary).await.is_ok() {
185            return Ok(NativeTsRuntimePreflight {
186                entrypoint: artifact.entrypoint,
187                artifact: artifact.binary,
188                source_hash: artifact.source_hash,
189                cache_hit: true,
190            });
191        }
192
193        tokio::fs::create_dir_all(&self.config.cache_dir).await?;
194        let output = Command::new(&self.config.compiler_binary)
195            .arg("compile")
196            .arg(&artifact.entrypoint)
197            .arg("-o")
198            .arg(&artifact.binary)
199            .current_dir(&self.config.working_dir)
200            .output()
201            .await?;
202
203        if !output.status.success() {
204            return Err(FlowError::Runtime(format!(
205                "native TypeScript compile failed: {}",
206                String::from_utf8_lossy(&output.stderr)
207            )));
208        }
209
210        tokio::fs::metadata(&artifact.binary).await.map_err(|err| {
211            FlowError::Runtime(format!(
212                "native TypeScript compiler did not produce artifact {}: {err}",
213                artifact.binary.display()
214            ))
215        })?;
216
217        Ok(NativeTsRuntimePreflight {
218            entrypoint: artifact.entrypoint,
219            artifact: artifact.binary,
220            source_hash: artifact.source_hash,
221            cache_hit: false,
222        })
223    }
224
225    #[cfg(feature = "native-ts")]
226    async fn invoke<I, O>(
227        &self,
228        spec: &WorkflowSpec,
229        kind: NativeRuntimeKind,
230        payload: I,
231    ) -> Result<O>
232    where
233        I: Serialize + Send,
234        O: DeserializeOwned,
235    {
236        let preflight = self.compile_if_needed(spec).await?;
237        let request = NativeRuntimeRequest::new(
238            kind,
239            spec.runtime.export_name.clone(),
240            preflight.source_hash,
241            payload,
242        );
243
244        let mut child = Command::new(&preflight.artifact)
245            .arg("--a3s-flow-runtime")
246            .stdin(std::process::Stdio::piped())
247            .stdout(std::process::Stdio::piped())
248            .stderr(std::process::Stdio::piped())
249            .current_dir(&self.config.working_dir)
250            .spawn()?;
251
252        let mut stdin = child
253            .stdin
254            .take()
255            .ok_or_else(|| FlowError::Runtime("failed to open runtime stdin".to_string()))?;
256        stdin
257            .write_all(serde_json::to_string(&request)?.as_bytes())
258            .await?;
259        stdin.shutdown().await?;
260        drop(stdin);
261
262        let output = child.wait_with_output().await?;
263        if !output.status.success() {
264            return Err(FlowError::Runtime(format!(
265                "native TypeScript runtime failed: {}",
266                String::from_utf8_lossy(&output.stderr)
267            )));
268        }
269
270        decode_native_response(kind, &output.stdout)
271    }
272}
273
274#[cfg(feature = "native-ts")]
275fn validate_native_ts_spec(spec: &WorkflowSpec) -> Result<()> {
276    spec.validate()?;
277    if spec.runtime.kind != RuntimeKind::NativeTs {
278        return Err(FlowError::InvalidWorkflow(format!(
279            "NativeTsRuntime requires a native_ts workflow spec, got {:?}",
280            spec.runtime.kind
281        )));
282    }
283    Ok(())
284}
285
286#[async_trait]
287impl FlowRuntime for NativeTsRuntime {
288    #[cfg(feature = "native-ts")]
289    async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
290        let spec = invocation.spec.clone();
291        self.invoke(&spec, NativeRuntimeKind::Workflow, invocation)
292            .await
293    }
294
295    #[cfg(not(feature = "native-ts"))]
296    async fn run_workflow(&self, _invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
297        Err(FlowError::Runtime(
298            "native-ts feature is disabled for NativeTsRuntime".to_string(),
299        ))
300    }
301
302    #[cfg(feature = "native-ts")]
303    async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue> {
304        let spec = workflow_spec_from_history(&invocation.history)?;
305        self.invoke(&spec, NativeRuntimeKind::Step, invocation)
306            .await
307    }
308
309    #[cfg(not(feature = "native-ts"))]
310    async fn run_step(&self, _invocation: StepInvocation) -> Result<JsonValue> {
311        Err(FlowError::Runtime(
312            "native-ts feature is disabled for NativeTsRuntime".to_string(),
313        ))
314    }
315}
316
317#[cfg(feature = "native-ts")]
318fn workflow_spec_from_history(history: &[FlowEventEnvelope]) -> Result<WorkflowSpec> {
319    let first = history
320        .first()
321        .ok_or_else(|| FlowError::Runtime("step invocation has empty history".to_string()))?;
322    match &first.event {
323        crate::model::FlowEvent::RunCreated { spec, .. } => Ok(spec.clone()),
324        _ => Err(FlowError::Runtime(
325            "first history event is not run_created".to_string(),
326        )),
327    }
328}
329
330#[cfg(feature = "native-ts")]
331fn sanitize_filename(value: &str) -> String {
332    value
333        .chars()
334        .map(|ch| {
335            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
336                ch
337            } else {
338                '-'
339            }
340        })
341        .collect()
342}
343
344#[cfg(feature = "native-ts")]
345fn resolve_against(root: &Path, value: &str) -> PathBuf {
346    let path = PathBuf::from(value);
347    if path.is_absolute() {
348        path
349    } else {
350        root.join(path)
351    }
352}
353
354#[cfg(feature = "native-ts")]
355fn stable_hash(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> String {
356    let mut hasher = Sha256::new();
357    for part in parts {
358        let bytes = part.as_ref();
359        hasher.update(bytes.len().to_le_bytes());
360        hasher.update(bytes);
361    }
362    hex_lower(&hasher.finalize())
363}
364
365#[cfg(feature = "native-ts")]
366fn hex_lower(bytes: &[u8]) -> String {
367    const HEX: &[u8; 16] = b"0123456789abcdef";
368    let mut output = String::with_capacity(bytes.len() * 2);
369    for byte in bytes {
370        output.push(HEX[(byte >> 4) as usize] as char);
371        output.push(HEX[(byte & 0x0f) as usize] as char);
372    }
373    output
374}
375
376#[cfg(feature = "native-ts")]
377fn decode_native_response<O>(kind: NativeRuntimeKind, bytes: &[u8]) -> Result<O>
378where
379    O: DeserializeOwned,
380{
381    let response: NativeRuntimeResponse = serde_json::from_slice(bytes)?;
382    if response.protocol != NATIVE_RUNTIME_PROTOCOL {
383        return Err(FlowError::Runtime(format!(
384            "native TypeScript runtime protocol mismatch: expected {NATIVE_RUNTIME_PROTOCOL}, got {}",
385            response.protocol
386        )));
387    }
388    if response.kind != kind {
389        return Err(FlowError::Runtime(format!(
390            "native TypeScript runtime response kind mismatch: expected {}, got {}",
391            kind.as_str(),
392            response.kind.as_str()
393        )));
394    }
395    if !response.ok {
396        let error = response
397            .error
398            .unwrap_or_else(|| "runtime returned ok=false without an error".to_string());
399        return Err(FlowError::Runtime(error));
400    }
401    let output = response.output.ok_or_else(|| {
402        FlowError::Runtime("native TypeScript runtime returned ok=true without output".to_string())
403    })?;
404    serde_json::from_value(output).map_err(FlowError::from)
405}