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    /// Compiler executable. Bare names use `PATH`; relative paths with a
83    /// directory component are resolved against the host process directory.
84    pub compiler_binary: PathBuf,
85    /// Artifact cache directory. Relative paths are resolved against the host
86    /// process directory before the compiler changes its working directory.
87    pub cache_dir: PathBuf,
88    /// Runtime working directory. Relative paths are resolved against the host
89    /// process directory, and workflow entrypoints are resolved from it.
90    pub working_dir: PathBuf,
91}
92
93impl NativeTsRuntimeConfig {
94    pub fn new(
95        compiler_binary: impl Into<PathBuf>,
96        cache_dir: impl Into<PathBuf>,
97        working_dir: impl Into<PathBuf>,
98    ) -> Self {
99        Self {
100            compiler_binary: compiler_binary.into(),
101            cache_dir: cache_dir.into(),
102            working_dir: working_dir.into(),
103        }
104    }
105}
106
107impl Default for NativeTsRuntimeConfig {
108    fn default() -> Self {
109        Self {
110            compiler_binary: PathBuf::from("a3s-flow-native-compiler"),
111            cache_dir: PathBuf::from(".a3s/flow/native-ts"),
112            working_dir: PathBuf::from("."),
113        }
114    }
115}
116
117/// Runtime that compiles TypeScript to a native executable and speaks JSON over
118/// stdin/stdout with that executable.
119#[derive(Debug, Clone)]
120pub struct NativeTsRuntime {
121    config: NativeTsRuntimeConfig,
122}
123
124#[cfg(feature = "native-ts")]
125#[derive(Debug, Clone)]
126struct NativeArtifact {
127    compiler_binary: PathBuf,
128    working_dir: PathBuf,
129    entrypoint: PathBuf,
130    binary: PathBuf,
131    source_hash: String,
132}
133
134/// Result of validating and compiling a native TypeScript workflow source.
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
136pub struct NativeTsRuntimePreflight {
137    /// Resolved workflow source entrypoint used by the compiler.
138    pub entrypoint: PathBuf,
139    /// Resolved native artifact path that will be invoked by the runtime.
140    pub artifact: PathBuf,
141    /// Stable hash of the workflow source and runtime identity fields.
142    pub source_hash: String,
143    /// True when the existing artifact cache entry was reused.
144    pub cache_hit: bool,
145}
146
147impl NativeTsRuntime {
148    pub fn new(config: NativeTsRuntimeConfig) -> Self {
149        Self { config }
150    }
151
152    pub fn config(&self) -> &NativeTsRuntimeConfig {
153        &self.config
154    }
155
156    #[cfg(feature = "native-ts")]
157    pub async fn preflight(&self, spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
158        let (artifact, cache_hit) = self.compile_if_needed(spec).await?;
159        Ok(NativeTsRuntimePreflight {
160            entrypoint: artifact.entrypoint,
161            artifact: artifact.binary,
162            source_hash: artifact.source_hash,
163            cache_hit,
164        })
165    }
166
167    #[cfg(not(feature = "native-ts"))]
168    pub async fn preflight(&self, _spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
169        Err(FlowError::Runtime(
170            "native-ts feature is disabled for NativeTsRuntime".to_string(),
171        ))
172    }
173
174    #[cfg(feature = "native-ts")]
175    async fn artifact_for(&self, spec: &WorkflowSpec) -> Result<NativeArtifact> {
176        validate_native_ts_spec(spec)?;
177        let compiler_binary = executable_from_current_dir(&self.config.compiler_binary)?;
178        let working_dir = absolute_from_current_dir(&self.config.working_dir)?;
179        let entrypoint = resolve_against(&working_dir, &spec.runtime.entrypoint);
180        let cache_dir = absolute_from_current_dir(&self.config.cache_dir)?;
181        let source = tokio::fs::read(&entrypoint).await?;
182        let source_hash = stable_hash([
183            b"source".as_slice(),
184            spec.name.as_bytes(),
185            spec.version.as_bytes(),
186            spec.runtime.entrypoint.as_bytes(),
187            spec.runtime.export_name.as_bytes(),
188            &source,
189        ]);
190        let name = format!("{}-{source_hash}", sanitize_filename(&spec.name));
191        Ok(NativeArtifact {
192            compiler_binary,
193            working_dir,
194            entrypoint,
195            binary: cache_dir.join(name),
196            source_hash,
197        })
198    }
199
200    #[cfg(feature = "native-ts")]
201    async fn compile_if_needed(&self, spec: &WorkflowSpec) -> Result<(NativeArtifact, bool)> {
202        let artifact = self.artifact_for(spec).await?;
203        if tokio::fs::metadata(&artifact.binary).await.is_ok() {
204            return Ok((artifact, true));
205        }
206
207        let cache_dir = artifact.binary.parent().ok_or_else(|| {
208            FlowError::Runtime(format!(
209                "native TypeScript artifact {} has no cache directory",
210                artifact.binary.display()
211            ))
212        })?;
213        tokio::fs::create_dir_all(cache_dir).await?;
214        let output = Command::new(&artifact.compiler_binary)
215            .arg("compile")
216            .arg(&artifact.entrypoint)
217            .arg("-o")
218            .arg(&artifact.binary)
219            .current_dir(&artifact.working_dir)
220            .output()
221            .await?;
222
223        if !output.status.success() {
224            return Err(FlowError::Runtime(format!(
225                "native TypeScript compile failed: {}",
226                String::from_utf8_lossy(&output.stderr)
227            )));
228        }
229
230        tokio::fs::metadata(&artifact.binary).await.map_err(|err| {
231            FlowError::Runtime(format!(
232                "native TypeScript compiler did not produce artifact {}: {err}",
233                artifact.binary.display()
234            ))
235        })?;
236
237        Ok((artifact, false))
238    }
239
240    #[cfg(feature = "native-ts")]
241    async fn invoke<I, O>(
242        &self,
243        spec: &WorkflowSpec,
244        kind: NativeRuntimeKind,
245        payload: I,
246    ) -> Result<O>
247    where
248        I: Serialize + Send,
249        O: DeserializeOwned,
250    {
251        let (artifact, _) = self.compile_if_needed(spec).await?;
252        let request = NativeRuntimeRequest::new(
253            kind,
254            spec.runtime.export_name.clone(),
255            artifact.source_hash,
256            payload,
257        );
258
259        let mut child = Command::new(&artifact.binary)
260            .arg("--a3s-flow-runtime")
261            .stdin(std::process::Stdio::piped())
262            .stdout(std::process::Stdio::piped())
263            .stderr(std::process::Stdio::piped())
264            .current_dir(&artifact.working_dir)
265            .spawn()?;
266
267        let mut stdin = child
268            .stdin
269            .take()
270            .ok_or_else(|| FlowError::Runtime("failed to open runtime stdin".to_string()))?;
271        stdin
272            .write_all(serde_json::to_string(&request)?.as_bytes())
273            .await?;
274        stdin.shutdown().await?;
275        drop(stdin);
276
277        let output = child.wait_with_output().await?;
278        if !output.status.success() {
279            return Err(FlowError::Runtime(format!(
280                "native TypeScript runtime failed: {}",
281                String::from_utf8_lossy(&output.stderr)
282            )));
283        }
284
285        decode_native_response(kind, &output.stdout)
286    }
287}
288
289#[cfg(feature = "native-ts")]
290fn validate_native_ts_spec(spec: &WorkflowSpec) -> Result<()> {
291    spec.validate()?;
292    if spec.runtime.kind != RuntimeKind::NativeTs {
293        return Err(FlowError::InvalidWorkflow(format!(
294            "NativeTsRuntime requires a native_ts workflow spec, got {:?}",
295            spec.runtime.kind
296        )));
297    }
298    Ok(())
299}
300
301#[async_trait]
302impl FlowRuntime for NativeTsRuntime {
303    #[cfg(feature = "native-ts")]
304    async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
305        let spec = invocation.spec.clone();
306        self.invoke(&spec, NativeRuntimeKind::Workflow, invocation)
307            .await
308    }
309
310    #[cfg(not(feature = "native-ts"))]
311    async fn run_workflow(&self, _invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
312        Err(FlowError::Runtime(
313            "native-ts feature is disabled for NativeTsRuntime".to_string(),
314        ))
315    }
316
317    #[cfg(feature = "native-ts")]
318    async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue> {
319        let spec = workflow_spec_from_history(&invocation.history)?;
320        self.invoke(&spec, NativeRuntimeKind::Step, invocation)
321            .await
322    }
323
324    #[cfg(not(feature = "native-ts"))]
325    async fn run_step(&self, _invocation: StepInvocation) -> Result<JsonValue> {
326        Err(FlowError::Runtime(
327            "native-ts feature is disabled for NativeTsRuntime".to_string(),
328        ))
329    }
330}
331
332#[cfg(feature = "native-ts")]
333fn workflow_spec_from_history(history: &[FlowEventEnvelope]) -> Result<WorkflowSpec> {
334    let first = history
335        .first()
336        .ok_or_else(|| FlowError::Runtime("step invocation has empty history".to_string()))?;
337    match &first.event {
338        crate::model::FlowEvent::RunCreated { spec, .. } => Ok(spec.clone()),
339        _ => Err(FlowError::Runtime(
340            "first history event is not run_created".to_string(),
341        )),
342    }
343}
344
345#[cfg(feature = "native-ts")]
346fn sanitize_filename(value: &str) -> String {
347    value
348        .chars()
349        .map(|ch| {
350            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
351                ch
352            } else {
353                '-'
354            }
355        })
356        .collect()
357}
358
359#[cfg(feature = "native-ts")]
360fn resolve_against(root: &Path, value: &str) -> PathBuf {
361    let path = PathBuf::from(value);
362    if path.is_absolute() {
363        path
364    } else {
365        root.join(path)
366    }
367}
368
369#[cfg(feature = "native-ts")]
370fn absolute_from_current_dir(path: &Path) -> Result<PathBuf> {
371    if path.is_absolute() {
372        return Ok(path.to_path_buf());
373    }
374    Ok(std::env::current_dir()?.join(path))
375}
376
377#[cfg(feature = "native-ts")]
378fn executable_from_current_dir(path: &Path) -> Result<PathBuf> {
379    if path.components().count() == 1 {
380        return Ok(path.to_path_buf());
381    }
382    absolute_from_current_dir(path)
383}
384
385#[cfg(feature = "native-ts")]
386fn stable_hash(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> String {
387    let mut hasher = Sha256::new();
388    for part in parts {
389        let bytes = part.as_ref();
390        hasher.update(bytes.len().to_le_bytes());
391        hasher.update(bytes);
392    }
393    hex_lower(&hasher.finalize())
394}
395
396#[cfg(feature = "native-ts")]
397fn hex_lower(bytes: &[u8]) -> String {
398    const HEX: &[u8; 16] = b"0123456789abcdef";
399    let mut output = String::with_capacity(bytes.len() * 2);
400    for byte in bytes {
401        output.push(HEX[(byte >> 4) as usize] as char);
402        output.push(HEX[(byte & 0x0f) as usize] as char);
403    }
404    output
405}
406
407#[cfg(feature = "native-ts")]
408fn decode_native_response<O>(kind: NativeRuntimeKind, bytes: &[u8]) -> Result<O>
409where
410    O: DeserializeOwned,
411{
412    let response: NativeRuntimeResponse = serde_json::from_slice(bytes)?;
413    if response.protocol != NATIVE_RUNTIME_PROTOCOL {
414        return Err(FlowError::Runtime(format!(
415            "native TypeScript runtime protocol mismatch: expected {NATIVE_RUNTIME_PROTOCOL}, got {}",
416            response.protocol
417        )));
418    }
419    if response.kind != kind {
420        return Err(FlowError::Runtime(format!(
421            "native TypeScript runtime response kind mismatch: expected {}, got {}",
422            kind.as_str(),
423            response.kind.as_str()
424        )));
425    }
426    if !response.ok {
427        let error = response
428            .error
429            .unwrap_or_else(|| "runtime returned ok=false without an error".to_string());
430        return Err(FlowError::Runtime(error));
431    }
432    let output = response.output.ok_or_else(|| {
433        FlowError::Runtime("native TypeScript runtime returned ok=true without output".to_string())
434    })?;
435    serde_json::from_value(output).map_err(FlowError::from)
436}
437
438#[cfg(test)]
439mod tests {
440    use super::NativeTsRuntimeConfig;
441    use std::path::Path;
442
443    #[test]
444    fn native_ts_default_cache_stays_under_a3s_state_root() {
445        let config = NativeTsRuntimeConfig::default();
446
447        assert_eq!(config.cache_dir, Path::new(".a3s/flow/native-ts"));
448    }
449}