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#[cfg(feature = "native-ts")]
15use uuid::Uuid;
16
17use crate::context::WorkflowContext;
18use crate::error::{FlowError, Result};
19#[cfg(feature = "native-ts")]
20use crate::model::RuntimeKind;
21use crate::model::{FlowEventEnvelope, JsonValue, RuntimeCommand, WorkflowSpec};
22#[cfg(feature = "native-ts")]
23use crate::protocol::{
24    NativeRuntimeKind, NativeRuntimeRequest, NativeRuntimeResponse, NATIVE_RUNTIME_PROTOCOL,
25};
26
27/// Workflow replay request passed to a runtime implementation.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct WorkflowInvocation {
30    pub run_id: String,
31    pub spec: WorkflowSpec,
32    pub input: JsonValue,
33    pub history: Vec<FlowEventEnvelope>,
34}
35
36impl WorkflowInvocation {
37    /// Build a deterministic helper view over this workflow invocation.
38    pub fn context(&self) -> WorkflowContext<'_> {
39        WorkflowContext::new(self)
40    }
41
42    /// Decode the workflow input into a host-defined serde type.
43    pub fn input_as<T>(&self) -> Result<T>
44    where
45        T: DeserializeOwned,
46    {
47        serde_json::from_value(self.input.clone()).map_err(FlowError::from)
48    }
49}
50
51/// Step execution request passed to a runtime implementation.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct StepInvocation {
54    pub run_id: String,
55    pub step_id: String,
56    pub step_name: String,
57    pub input: JsonValue,
58    pub history: Vec<FlowEventEnvelope>,
59}
60
61impl StepInvocation {
62    /// Decode the step input into a host-defined serde type.
63    pub fn input_as<T>(&self) -> Result<T>
64    where
65        T: DeserializeOwned,
66    {
67        serde_json::from_value(self.input.clone()).map_err(FlowError::from)
68    }
69}
70
71/// Runtime boundary for workflow code and side-effecting steps.
72#[async_trait]
73pub trait FlowRuntime: Send + Sync {
74    /// Replay the deterministic workflow function and return the next command.
75    async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand>;
76
77    /// Execute one side-effecting step. The engine persists success/failure.
78    async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue>;
79}
80
81/// Configuration for the native TypeScript runtime adapter.
82#[derive(Debug, Clone)]
83pub struct NativeTsRuntimeConfig {
84    /// Compiler executable. Bare names use `PATH`; relative paths with a
85    /// directory component are resolved against the host process directory.
86    pub compiler_binary: PathBuf,
87    /// Artifact cache directory. Relative paths are resolved against the host
88    /// process directory before the compiler changes its working directory.
89    pub cache_dir: PathBuf,
90    /// Runtime working directory. Relative paths are resolved against the host
91    /// process directory, and workflow entrypoints are resolved from it.
92    pub working_dir: PathBuf,
93}
94
95impl NativeTsRuntimeConfig {
96    pub fn new(
97        compiler_binary: impl Into<PathBuf>,
98        cache_dir: impl Into<PathBuf>,
99        working_dir: impl Into<PathBuf>,
100    ) -> Self {
101        Self {
102            compiler_binary: compiler_binary.into(),
103            cache_dir: cache_dir.into(),
104            working_dir: working_dir.into(),
105        }
106    }
107}
108
109impl Default for NativeTsRuntimeConfig {
110    fn default() -> Self {
111        Self {
112            compiler_binary: PathBuf::from("a3s-flow-native-compiler"),
113            cache_dir: PathBuf::from(".a3s/flow/native-ts"),
114            working_dir: PathBuf::from("."),
115        }
116    }
117}
118
119/// Runtime that compiles TypeScript to a native executable and speaks JSON over
120/// stdin/stdout with that executable.
121#[derive(Debug, Clone)]
122pub struct NativeTsRuntime {
123    config: NativeTsRuntimeConfig,
124}
125
126#[cfg(feature = "native-ts")]
127#[derive(Debug, Clone)]
128struct NativeArtifact {
129    compiler_binary: PathBuf,
130    working_dir: PathBuf,
131    entrypoint: PathBuf,
132    binary: PathBuf,
133    source_hash: String,
134}
135
136/// Result of validating and compiling a native TypeScript workflow source.
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138pub struct NativeTsRuntimePreflight {
139    /// Resolved workflow source entrypoint used by the compiler.
140    pub entrypoint: PathBuf,
141    /// Resolved native artifact path that will be invoked by the runtime.
142    pub artifact: PathBuf,
143    /// Stable hash of the workflow source and runtime identity fields.
144    pub source_hash: String,
145    /// True when the existing artifact cache entry was reused.
146    pub cache_hit: bool,
147}
148
149impl NativeTsRuntime {
150    pub fn new(config: NativeTsRuntimeConfig) -> Self {
151        Self { config }
152    }
153
154    pub fn config(&self) -> &NativeTsRuntimeConfig {
155        &self.config
156    }
157
158    #[cfg(feature = "native-ts")]
159    pub async fn preflight(&self, spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
160        let (artifact, cache_hit) = self.compile_if_needed(spec).await?;
161        Ok(NativeTsRuntimePreflight {
162            entrypoint: artifact.entrypoint,
163            artifact: artifact.binary,
164            source_hash: artifact.source_hash,
165            cache_hit,
166        })
167    }
168
169    #[cfg(not(feature = "native-ts"))]
170    pub async fn preflight(&self, _spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
171        Err(FlowError::Runtime(
172            "native-ts feature is disabled for NativeTsRuntime".to_string(),
173        ))
174    }
175
176    #[cfg(feature = "native-ts")]
177    async fn artifact_for(&self, spec: &WorkflowSpec) -> Result<NativeArtifact> {
178        validate_native_ts_spec(spec)?;
179        let compiler_binary = executable_from_current_dir(&self.config.compiler_binary)?;
180        let working_dir = absolute_from_current_dir(&self.config.working_dir)?;
181        let entrypoint = resolve_against(&working_dir, &spec.runtime.entrypoint);
182        let cache_dir = absolute_from_current_dir(&self.config.cache_dir)?;
183        let source = tokio::fs::read(&entrypoint).await?;
184        let source_hash = stable_hash([
185            b"source".as_slice(),
186            spec.name.as_bytes(),
187            spec.version.as_bytes(),
188            spec.runtime.entrypoint.as_bytes(),
189            spec.runtime.export_name.as_bytes(),
190            &source,
191        ]);
192        let name = format!("{}-{source_hash}", sanitize_filename(&spec.name));
193        Ok(NativeArtifact {
194            compiler_binary,
195            working_dir,
196            entrypoint,
197            binary: cache_dir.join(name),
198            source_hash,
199        })
200    }
201
202    #[cfg(feature = "native-ts")]
203    async fn compile_if_needed(&self, spec: &WorkflowSpec) -> Result<(NativeArtifact, bool)> {
204        let artifact = self.artifact_for(spec).await?;
205        if tokio::fs::metadata(&artifact.binary).await.is_ok() {
206            return Ok((artifact, true));
207        }
208
209        let cache_dir = artifact.binary.parent().ok_or_else(|| {
210            FlowError::Runtime(format!(
211                "native TypeScript artifact {} has no cache directory",
212                artifact.binary.display()
213            ))
214        })?;
215        tokio::fs::create_dir_all(cache_dir).await?;
216        // Keep the shared cache entry invisible until the compiler has closed
217        // a complete artifact. Same-directory rename is the atomic publish
218        // boundary for concurrent preflight calls and processes.
219        let temporary_binary = temporary_artifact_path(&artifact.binary)?;
220        let output = match Command::new(&artifact.compiler_binary)
221            .arg("compile")
222            .arg(&artifact.entrypoint)
223            .arg("-o")
224            .arg(&temporary_binary)
225            .current_dir(&artifact.working_dir)
226            .output()
227            .await
228        {
229            Ok(output) => output,
230            Err(error) => {
231                remove_temporary_artifact(&temporary_binary).await;
232                return Err(error.into());
233            }
234        };
235
236        if !output.status.success() {
237            remove_temporary_artifact(&temporary_binary).await;
238            return Err(FlowError::Runtime(format!(
239                "native TypeScript compile failed: {}",
240                String::from_utf8_lossy(&output.stderr)
241            )));
242        }
243
244        if let Err(error) = tokio::fs::metadata(&temporary_binary).await {
245            remove_temporary_artifact(&temporary_binary).await;
246            return Err(FlowError::Runtime(format!(
247                "native TypeScript compiler did not produce artifact {}: {error}",
248                artifact.binary.display()
249            )));
250        }
251        publish_temporary_artifact(&temporary_binary, &artifact.binary).await?;
252
253        Ok((artifact, false))
254    }
255
256    #[cfg(feature = "native-ts")]
257    async fn invoke<I, O>(
258        &self,
259        spec: &WorkflowSpec,
260        kind: NativeRuntimeKind,
261        payload: I,
262    ) -> Result<O>
263    where
264        I: Serialize + Send,
265        O: DeserializeOwned,
266    {
267        let (artifact, _) = self.compile_if_needed(spec).await?;
268        let request = NativeRuntimeRequest::new(
269            kind,
270            spec.runtime.export_name.clone(),
271            artifact.source_hash,
272            payload,
273        );
274
275        let mut child = Command::new(&artifact.binary)
276            .arg("--a3s-flow-runtime")
277            .stdin(std::process::Stdio::piped())
278            .stdout(std::process::Stdio::piped())
279            .stderr(std::process::Stdio::piped())
280            .current_dir(&artifact.working_dir)
281            .spawn()?;
282
283        let mut stdin = child
284            .stdin
285            .take()
286            .ok_or_else(|| FlowError::Runtime("failed to open runtime stdin".to_string()))?;
287        stdin
288            .write_all(serde_json::to_string(&request)?.as_bytes())
289            .await?;
290        stdin.shutdown().await?;
291        drop(stdin);
292
293        let output = child.wait_with_output().await?;
294        if !output.status.success() {
295            return Err(FlowError::Runtime(format!(
296                "native TypeScript runtime failed: {}",
297                String::from_utf8_lossy(&output.stderr)
298            )));
299        }
300
301        decode_native_response(kind, &output.stdout)
302    }
303}
304
305#[cfg(feature = "native-ts")]
306fn validate_native_ts_spec(spec: &WorkflowSpec) -> Result<()> {
307    spec.validate()?;
308    if spec.runtime.kind != RuntimeKind::NativeTs {
309        return Err(FlowError::InvalidWorkflow(format!(
310            "NativeTsRuntime requires a native_ts workflow spec, got {:?}",
311            spec.runtime.kind
312        )));
313    }
314    Ok(())
315}
316
317#[async_trait]
318impl FlowRuntime for NativeTsRuntime {
319    #[cfg(feature = "native-ts")]
320    async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
321        let spec = invocation.spec.clone();
322        self.invoke(&spec, NativeRuntimeKind::Workflow, invocation)
323            .await
324    }
325
326    #[cfg(not(feature = "native-ts"))]
327    async fn run_workflow(&self, _invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
328        Err(FlowError::Runtime(
329            "native-ts feature is disabled for NativeTsRuntime".to_string(),
330        ))
331    }
332
333    #[cfg(feature = "native-ts")]
334    async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue> {
335        let spec = workflow_spec_from_history(&invocation.history)?;
336        self.invoke(&spec, NativeRuntimeKind::Step, invocation)
337            .await
338    }
339
340    #[cfg(not(feature = "native-ts"))]
341    async fn run_step(&self, _invocation: StepInvocation) -> Result<JsonValue> {
342        Err(FlowError::Runtime(
343            "native-ts feature is disabled for NativeTsRuntime".to_string(),
344        ))
345    }
346}
347
348#[cfg(feature = "native-ts")]
349fn workflow_spec_from_history(history: &[FlowEventEnvelope]) -> Result<WorkflowSpec> {
350    let first = history
351        .first()
352        .ok_or_else(|| FlowError::Runtime("step invocation has empty history".to_string()))?;
353    match &first.event {
354        crate::model::FlowEvent::RunCreated { spec, .. } => Ok(spec.clone()),
355        _ => Err(FlowError::Runtime(
356            "first history event is not run_created".to_string(),
357        )),
358    }
359}
360
361#[cfg(feature = "native-ts")]
362fn sanitize_filename(value: &str) -> String {
363    value
364        .chars()
365        .map(|ch| {
366            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
367                ch
368            } else {
369                '-'
370            }
371        })
372        .collect()
373}
374
375#[cfg(feature = "native-ts")]
376fn resolve_against(root: &Path, value: &str) -> PathBuf {
377    let path = PathBuf::from(value);
378    if path.is_absolute() {
379        path
380    } else {
381        root.join(path)
382    }
383}
384
385#[cfg(feature = "native-ts")]
386fn absolute_from_current_dir(path: &Path) -> Result<PathBuf> {
387    if path.is_absolute() {
388        return Ok(path.to_path_buf());
389    }
390    Ok(std::env::current_dir()?.join(path))
391}
392
393#[cfg(feature = "native-ts")]
394fn executable_from_current_dir(path: &Path) -> Result<PathBuf> {
395    if path.components().count() == 1 {
396        return Ok(path.to_path_buf());
397    }
398    absolute_from_current_dir(path)
399}
400
401#[cfg(feature = "native-ts")]
402fn temporary_artifact_path(artifact: &Path) -> Result<PathBuf> {
403    let file_name = artifact.file_name().ok_or_else(|| {
404        FlowError::Runtime(format!(
405            "native TypeScript artifact {} has no file name",
406            artifact.display()
407        ))
408    })?;
409    let temporary_name = format!(".{}.{}.tmp", file_name.to_string_lossy(), Uuid::new_v4());
410    Ok(artifact.with_file_name(temporary_name))
411}
412
413#[cfg(feature = "native-ts")]
414async fn publish_temporary_artifact(temporary: &Path, artifact: &Path) -> Result<()> {
415    match tokio::fs::rename(temporary, artifact).await {
416        Ok(()) => Ok(()),
417        Err(rename_error) => {
418            if tokio::fs::metadata(artifact).await.is_ok() {
419                remove_temporary_artifact(temporary).await;
420                return Ok(());
421            }
422            remove_temporary_artifact(temporary).await;
423            Err(FlowError::Runtime(format!(
424                "native TypeScript artifact {} could not be published atomically: {rename_error}",
425                artifact.display()
426            )))
427        }
428    }
429}
430
431#[cfg(feature = "native-ts")]
432async fn remove_temporary_artifact(path: &Path) {
433    match tokio::fs::remove_file(path).await {
434        Ok(()) => {}
435        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
436        Err(error) => tracing::warn!(
437            path = %path.display(),
438            %error,
439            "failed to remove temporary native TypeScript artifact"
440        ),
441    }
442}
443
444#[cfg(feature = "native-ts")]
445fn stable_hash(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> String {
446    let mut hasher = Sha256::new();
447    for part in parts {
448        let bytes = part.as_ref();
449        hasher.update(bytes.len().to_le_bytes());
450        hasher.update(bytes);
451    }
452    hex_lower(&hasher.finalize())
453}
454
455#[cfg(feature = "native-ts")]
456fn hex_lower(bytes: &[u8]) -> String {
457    const HEX: &[u8; 16] = b"0123456789abcdef";
458    let mut output = String::with_capacity(bytes.len() * 2);
459    for byte in bytes {
460        output.push(HEX[(byte >> 4) as usize] as char);
461        output.push(HEX[(byte & 0x0f) as usize] as char);
462    }
463    output
464}
465
466#[cfg(feature = "native-ts")]
467fn decode_native_response<O>(kind: NativeRuntimeKind, bytes: &[u8]) -> Result<O>
468where
469    O: DeserializeOwned,
470{
471    let response: NativeRuntimeResponse = serde_json::from_slice(bytes)?;
472    if response.protocol != NATIVE_RUNTIME_PROTOCOL {
473        return Err(FlowError::Runtime(format!(
474            "native TypeScript runtime protocol mismatch: expected {NATIVE_RUNTIME_PROTOCOL}, got {}",
475            response.protocol
476        )));
477    }
478    if response.kind != kind {
479        return Err(FlowError::Runtime(format!(
480            "native TypeScript runtime response kind mismatch: expected {}, got {}",
481            kind.as_str(),
482            response.kind.as_str()
483        )));
484    }
485    if !response.ok {
486        let error = response
487            .error
488            .unwrap_or_else(|| "runtime returned ok=false without an error".to_string());
489        return Err(FlowError::Runtime(error));
490    }
491    let output = response.output.ok_or_else(|| {
492        FlowError::Runtime("native TypeScript runtime returned ok=true without output".to_string())
493    })?;
494    serde_json::from_value(output).map_err(FlowError::from)
495}
496
497#[cfg(test)]
498mod tests {
499    use super::NativeTsRuntimeConfig;
500    use std::path::Path;
501
502    #[test]
503    fn native_ts_default_cache_stays_under_a3s_state_root() {
504        let config = NativeTsRuntimeConfig::default();
505
506        assert_eq!(config.cache_dir, Path::new(".a3s/flow/native-ts"));
507    }
508}