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