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#[cfg(feature = "native-ts")]
137struct TemporaryArtifactGuard {
138    path: PathBuf,
139    armed: bool,
140}
141
142#[cfg(feature = "native-ts")]
143impl TemporaryArtifactGuard {
144    fn new(path: PathBuf) -> Self {
145        Self { path, armed: true }
146    }
147
148    fn path(&self) -> &Path {
149        &self.path
150    }
151
152    async fn remove(&mut self) {
153        remove_temporary_artifact(&self.path).await;
154        self.armed = false;
155    }
156
157    fn disarm(&mut self) {
158        self.armed = false;
159    }
160}
161
162#[cfg(feature = "native-ts")]
163impl Drop for TemporaryArtifactGuard {
164    fn drop(&mut self) {
165        if !self.armed {
166            return;
167        }
168
169        let path = self.path.clone();
170        match tokio::runtime::Handle::try_current() {
171            Ok(runtime) => {
172                let _cleanup = runtime.spawn(async move {
173                    remove_temporary_artifact(&path).await;
174                });
175            }
176            Err(error) => tracing::warn!(
177                path = %self.path.display(),
178                %error,
179                "failed to schedule cancelled native TypeScript artifact cleanup"
180            ),
181        }
182    }
183}
184
185/// Result of validating and compiling a native TypeScript workflow source.
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
187pub struct NativeTsRuntimePreflight {
188    /// Resolved workflow source entrypoint used by the compiler.
189    pub entrypoint: PathBuf,
190    /// Resolved native artifact path that will be invoked by the runtime.
191    pub artifact: PathBuf,
192    /// Stable hash of the workflow source and runtime identity fields.
193    pub source_hash: String,
194    /// True when the existing artifact cache entry was reused.
195    pub cache_hit: bool,
196}
197
198impl NativeTsRuntime {
199    pub fn new(config: NativeTsRuntimeConfig) -> Self {
200        Self { config }
201    }
202
203    pub fn config(&self) -> &NativeTsRuntimeConfig {
204        &self.config
205    }
206
207    #[cfg(feature = "native-ts")]
208    pub async fn preflight(&self, spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
209        let (artifact, cache_hit) = self.compile_if_needed(spec).await?;
210        Ok(NativeTsRuntimePreflight {
211            entrypoint: artifact.entrypoint,
212            artifact: artifact.binary,
213            source_hash: artifact.source_hash,
214            cache_hit,
215        })
216    }
217
218    #[cfg(not(feature = "native-ts"))]
219    pub async fn preflight(&self, _spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
220        Err(FlowError::Runtime(
221            "native-ts feature is disabled for NativeTsRuntime".to_string(),
222        ))
223    }
224
225    #[cfg(feature = "native-ts")]
226    async fn artifact_for(&self, spec: &WorkflowSpec) -> Result<NativeArtifact> {
227        validate_native_ts_spec(spec)?;
228        let compiler_binary = executable_from_current_dir(&self.config.compiler_binary)?;
229        let working_dir = absolute_from_current_dir(&self.config.working_dir)?;
230        let entrypoint = resolve_against(&working_dir, &spec.runtime.entrypoint);
231        let cache_dir = absolute_from_current_dir(&self.config.cache_dir)?;
232        let source = tokio::fs::read(&entrypoint).await?;
233        // Keep the protocol-visible source hash portable, but scope the local
234        // native executable cache to every compile-environment input that can
235        // make identical workflow source produce an incompatible artifact.
236        let source_hash = native_source_hash(spec, &source);
237        let artifact_hash = native_artifact_cache_key(
238            &source_hash,
239            &compiler_binary,
240            &working_dir,
241            &entrypoint,
242            NATIVE_RUNTIME_PROTOCOL,
243        );
244        let name = format!("{}-{artifact_hash}", sanitize_filename(&spec.name));
245        Ok(NativeArtifact {
246            compiler_binary,
247            working_dir,
248            entrypoint,
249            binary: cache_dir.join(name),
250            source_hash,
251        })
252    }
253
254    #[cfg(feature = "native-ts")]
255    async fn compile_if_needed(&self, spec: &WorkflowSpec) -> Result<(NativeArtifact, bool)> {
256        let artifact = self.artifact_for(spec).await?;
257        if tokio::fs::metadata(&artifact.binary).await.is_ok() {
258            return Ok((artifact, true));
259        }
260
261        let cache_dir = artifact.binary.parent().ok_or_else(|| {
262            FlowError::Runtime(format!(
263                "native TypeScript artifact {} has no cache directory",
264                artifact.binary.display()
265            ))
266        })?;
267        tokio::fs::create_dir_all(cache_dir).await?;
268        // Keep the shared cache entry invisible until the compiler has closed
269        // a complete artifact. Same-directory rename is the atomic publish
270        // boundary for concurrent preflight calls and processes.
271        let mut temporary_binary =
272            TemporaryArtifactGuard::new(temporary_artifact_path(&artifact.binary)?);
273        let output = match Command::new(&artifact.compiler_binary)
274            .arg("compile")
275            .arg(&artifact.entrypoint)
276            .arg("-o")
277            .arg(temporary_binary.path())
278            .current_dir(&artifact.working_dir)
279            // A cancelled preflight must not leave the compiler running after
280            // its Rust future and temporary-artifact cleanup have disappeared.
281            .kill_on_drop(true)
282            .output()
283            .await
284        {
285            Ok(output) => output,
286            Err(error) => {
287                temporary_binary.remove().await;
288                return Err(error.into());
289            }
290        };
291
292        if !output.status.success() {
293            temporary_binary.remove().await;
294            return Err(FlowError::Runtime(format!(
295                "native TypeScript compile failed: {}",
296                String::from_utf8_lossy(&output.stderr)
297            )));
298        }
299
300        if let Err(error) = tokio::fs::metadata(temporary_binary.path()).await {
301            temporary_binary.remove().await;
302            return Err(FlowError::Runtime(format!(
303                "native TypeScript compiler did not produce artifact {}: {error}",
304                artifact.binary.display()
305            )));
306        }
307        publish_temporary_artifact(temporary_binary.path(), &artifact.binary).await?;
308        temporary_binary.disarm();
309
310        Ok((artifact, false))
311    }
312
313    #[cfg(feature = "native-ts")]
314    async fn invoke<I, O>(
315        &self,
316        spec: &WorkflowSpec,
317        kind: NativeRuntimeKind,
318        payload: I,
319    ) -> Result<O>
320    where
321        I: Serialize + Send,
322        O: DeserializeOwned,
323    {
324        let (artifact, _) = self.compile_if_needed(spec).await?;
325        let request = NativeRuntimeRequest::new(
326            kind,
327            spec.runtime.export_name.clone(),
328            artifact.source_hash,
329            payload,
330        );
331
332        let mut child = Command::new(&artifact.binary)
333            .arg("--a3s-flow-runtime")
334            .stdin(std::process::Stdio::piped())
335            .stdout(std::process::Stdio::piped())
336            .stderr(std::process::Stdio::piped())
337            .current_dir(&artifact.working_dir)
338            // Boot timeouts, lease loss, shutdown, and caller cancellation all
339            // drop this future. Tie the direct artifact process to that owner.
340            .kill_on_drop(true)
341            .spawn()?;
342
343        let mut stdin = child
344            .stdin
345            .take()
346            .ok_or_else(|| FlowError::Runtime("failed to open runtime stdin".to_string()))?;
347        stdin
348            .write_all(serde_json::to_string(&request)?.as_bytes())
349            .await?;
350        stdin.shutdown().await?;
351        drop(stdin);
352
353        let output = child.wait_with_output().await?;
354        if !output.status.success() {
355            return Err(FlowError::Runtime(format!(
356                "native TypeScript runtime failed: {}",
357                String::from_utf8_lossy(&output.stderr)
358            )));
359        }
360
361        decode_native_response(kind, &output.stdout)
362    }
363}
364
365#[cfg(feature = "native-ts")]
366fn native_source_hash(spec: &WorkflowSpec, source: &[u8]) -> String {
367    stable_hash([
368        b"source".as_slice(),
369        spec.name.as_bytes(),
370        spec.version.as_bytes(),
371        spec.runtime.entrypoint.as_bytes(),
372        spec.runtime.export_name.as_bytes(),
373        source,
374    ])
375}
376
377#[cfg(feature = "native-ts")]
378fn native_artifact_cache_key(
379    source_hash: &str,
380    compiler_binary: &Path,
381    working_dir: &Path,
382    entrypoint: &Path,
383    protocol: &str,
384) -> String {
385    stable_hash([
386        b"a3s.flow.native_ts.artifact.v1".as_slice(),
387        source_hash.as_bytes(),
388        protocol.as_bytes(),
389        compiler_binary.as_os_str().as_encoded_bytes(),
390        working_dir.as_os_str().as_encoded_bytes(),
391        entrypoint.as_os_str().as_encoded_bytes(),
392        std::env::consts::OS.as_bytes(),
393        std::env::consts::ARCH.as_bytes(),
394    ])
395}
396
397#[cfg(feature = "native-ts")]
398fn validate_native_ts_spec(spec: &WorkflowSpec) -> Result<()> {
399    spec.validate()?;
400    if spec.runtime.kind != RuntimeKind::NativeTs {
401        return Err(FlowError::InvalidWorkflow(format!(
402            "NativeTsRuntime requires a native_ts workflow spec, got {:?}",
403            spec.runtime.kind
404        )));
405    }
406    Ok(())
407}
408
409#[async_trait]
410impl FlowRuntime for NativeTsRuntime {
411    #[cfg(feature = "native-ts")]
412    async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
413        let spec = invocation.spec.clone();
414        self.invoke(&spec, NativeRuntimeKind::Workflow, invocation)
415            .await
416    }
417
418    #[cfg(not(feature = "native-ts"))]
419    async fn run_workflow(&self, _invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
420        Err(FlowError::Runtime(
421            "native-ts feature is disabled for NativeTsRuntime".to_string(),
422        ))
423    }
424
425    #[cfg(feature = "native-ts")]
426    async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue> {
427        let spec = workflow_spec_from_history(&invocation.history)?;
428        self.invoke(&spec, NativeRuntimeKind::Step, invocation)
429            .await
430    }
431
432    #[cfg(not(feature = "native-ts"))]
433    async fn run_step(&self, _invocation: StepInvocation) -> Result<JsonValue> {
434        Err(FlowError::Runtime(
435            "native-ts feature is disabled for NativeTsRuntime".to_string(),
436        ))
437    }
438}
439
440#[cfg(feature = "native-ts")]
441fn workflow_spec_from_history(history: &[FlowEventEnvelope]) -> Result<WorkflowSpec> {
442    let first = history
443        .first()
444        .ok_or_else(|| FlowError::Runtime("step invocation has empty history".to_string()))?;
445    match &first.event {
446        crate::model::FlowEvent::RunCreated { spec, .. } => Ok(spec.clone()),
447        _ => Err(FlowError::Runtime(
448            "first history event is not run_created".to_string(),
449        )),
450    }
451}
452
453#[cfg(feature = "native-ts")]
454fn sanitize_filename(value: &str) -> String {
455    value
456        .chars()
457        .map(|ch| {
458            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
459                ch
460            } else {
461                '-'
462            }
463        })
464        .collect()
465}
466
467#[cfg(feature = "native-ts")]
468fn resolve_against(root: &Path, value: &str) -> PathBuf {
469    let path = PathBuf::from(value);
470    if path.is_absolute() {
471        path
472    } else {
473        root.join(path)
474    }
475}
476
477#[cfg(feature = "native-ts")]
478fn absolute_from_current_dir(path: &Path) -> Result<PathBuf> {
479    if path.is_absolute() {
480        return Ok(path.to_path_buf());
481    }
482    Ok(std::env::current_dir()?.join(path))
483}
484
485#[cfg(feature = "native-ts")]
486fn executable_from_current_dir(path: &Path) -> Result<PathBuf> {
487    if path.components().count() == 1 {
488        return Ok(path.to_path_buf());
489    }
490    absolute_from_current_dir(path)
491}
492
493#[cfg(feature = "native-ts")]
494fn temporary_artifact_path(artifact: &Path) -> Result<PathBuf> {
495    let file_name = artifact.file_name().ok_or_else(|| {
496        FlowError::Runtime(format!(
497            "native TypeScript artifact {} has no file name",
498            artifact.display()
499        ))
500    })?;
501    let temporary_name = format!(".{}.{}.tmp", file_name.to_string_lossy(), Uuid::new_v4());
502    Ok(artifact.with_file_name(temporary_name))
503}
504
505#[cfg(feature = "native-ts")]
506async fn publish_temporary_artifact(temporary: &Path, artifact: &Path) -> Result<()> {
507    match tokio::fs::rename(temporary, artifact).await {
508        Ok(()) => Ok(()),
509        Err(rename_error) => {
510            if tokio::fs::metadata(artifact).await.is_ok() {
511                remove_temporary_artifact(temporary).await;
512                return Ok(());
513            }
514            remove_temporary_artifact(temporary).await;
515            Err(FlowError::Runtime(format!(
516                "native TypeScript artifact {} could not be published atomically: {rename_error}",
517                artifact.display()
518            )))
519        }
520    }
521}
522
523#[cfg(feature = "native-ts")]
524async fn remove_temporary_artifact(path: &Path) {
525    match tokio::fs::remove_file(path).await {
526        Ok(()) => {}
527        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
528        Err(error) => tracing::warn!(
529            path = %path.display(),
530            %error,
531            "failed to remove temporary native TypeScript artifact"
532        ),
533    }
534}
535
536#[cfg(feature = "native-ts")]
537fn stable_hash(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> String {
538    let mut hasher = Sha256::new();
539    for part in parts {
540        let bytes = part.as_ref();
541        hasher.update(bytes.len().to_le_bytes());
542        hasher.update(bytes);
543    }
544    hex_lower(&hasher.finalize())
545}
546
547#[cfg(feature = "native-ts")]
548fn hex_lower(bytes: &[u8]) -> String {
549    const HEX: &[u8; 16] = b"0123456789abcdef";
550    let mut output = String::with_capacity(bytes.len() * 2);
551    for byte in bytes {
552        output.push(HEX[(byte >> 4) as usize] as char);
553        output.push(HEX[(byte & 0x0f) as usize] as char);
554    }
555    output
556}
557
558#[cfg(feature = "native-ts")]
559fn decode_native_response<O>(kind: NativeRuntimeKind, bytes: &[u8]) -> Result<O>
560where
561    O: DeserializeOwned,
562{
563    let response: NativeRuntimeResponse = serde_json::from_slice(bytes)?;
564    if response.protocol != NATIVE_RUNTIME_PROTOCOL {
565        return Err(FlowError::Runtime(format!(
566            "native TypeScript runtime protocol mismatch: expected {NATIVE_RUNTIME_PROTOCOL}, got {}",
567            response.protocol
568        )));
569    }
570    if response.kind != kind {
571        return Err(FlowError::Runtime(format!(
572            "native TypeScript runtime response kind mismatch: expected {}, got {}",
573            kind.as_str(),
574            response.kind.as_str()
575        )));
576    }
577    if !response.ok {
578        let error = response
579            .error
580            .unwrap_or_else(|| "runtime returned ok=false without an error".to_string());
581        return Err(FlowError::Runtime(error));
582    }
583    let output = response.output.ok_or_else(|| {
584        FlowError::Runtime("native TypeScript runtime returned ok=true without output".to_string())
585    })?;
586    serde_json::from_value(output).map_err(FlowError::from)
587}
588
589#[cfg(test)]
590mod tests {
591    #[cfg(feature = "native-ts")]
592    use super::native_artifact_cache_key;
593    use super::NativeTsRuntimeConfig;
594    use std::path::Path;
595
596    #[test]
597    fn native_ts_default_cache_stays_under_a3s_state_root() {
598        let config = NativeTsRuntimeConfig::default();
599
600        assert_eq!(config.cache_dir, Path::new(".a3s/flow/native-ts"));
601    }
602
603    #[cfg(feature = "native-ts")]
604    #[test]
605    fn native_ts_artifact_cache_key_covers_the_compile_environment() {
606        let identity = |source, compiler, working_dir, entrypoint, protocol| {
607            native_artifact_cache_key(
608                source,
609                Path::new(compiler),
610                Path::new(working_dir),
611                Path::new(entrypoint),
612                protocol,
613            )
614        };
615        let baseline = identity(
616            "source-a",
617            "/compiler-a",
618            "/workspace-a",
619            "/workspace-a/workflow.ts",
620            "protocol-a",
621        );
622        let variants = [
623            (
624                "source-b",
625                "/compiler-a",
626                "/workspace-a",
627                "/workspace-a/workflow.ts",
628                "protocol-a",
629            ),
630            (
631                "source-a",
632                "/compiler-b",
633                "/workspace-a",
634                "/workspace-a/workflow.ts",
635                "protocol-a",
636            ),
637            (
638                "source-a",
639                "/compiler-a",
640                "/workspace-b",
641                "/workspace-a/workflow.ts",
642                "protocol-a",
643            ),
644            (
645                "source-a",
646                "/compiler-a",
647                "/workspace-a",
648                "/workspace-b/workflow.ts",
649                "protocol-a",
650            ),
651            (
652                "source-a",
653                "/compiler-a",
654                "/workspace-a",
655                "/workspace-a/workflow.ts",
656                "protocol-b",
657            ),
658        ];
659
660        for (source, compiler, working_dir, entrypoint, protocol) in variants {
661            assert_ne!(
662                identity(source, compiler, working_dir, entrypoint, protocol),
663                baseline
664            );
665        }
666    }
667}