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;
9use std::time::Duration;
10
11use crate::context::WorkflowContext;
12use crate::error::{FlowError, Result};
13#[cfg(feature = "native-ts")]
14use crate::model::RuntimeKind;
15use crate::model::{FlowEventEnvelope, JsonValue, RuntimeCommand, WorkflowSpec};
16#[cfg(feature = "native-ts")]
17use crate::protocol::{
18    NativeRuntimeKind, NativeRuntimeRequest, NativeRuntimeResponse, NATIVE_RUNTIME_PROTOCOL,
19};
20#[cfg(feature = "native-ts")]
21use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
22#[cfg(feature = "native-ts")]
23use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
24
25#[cfg(feature = "native-ts")]
26mod native_ts;
27
28#[cfg(feature = "native-ts")]
29use native_ts::{
30    artifact_binary_path, ArtifactCache, ArtifactCacheState, CompilerIdentityCache, SourceSnapshot,
31    TemporaryCacheEntryGuard,
32};
33
34/// Workflow replay request passed to a runtime implementation.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct WorkflowInvocation {
37    pub run_id: String,
38    pub spec: WorkflowSpec,
39    pub input: JsonValue,
40    pub history: Vec<FlowEventEnvelope>,
41}
42
43impl WorkflowInvocation {
44    /// Build a deterministic helper view over this workflow invocation.
45    pub fn context(&self) -> WorkflowContext<'_> {
46        WorkflowContext::new(self)
47    }
48
49    /// Decode the workflow input into a host-defined serde type.
50    pub fn input_as<T>(&self) -> Result<T>
51    where
52        T: DeserializeOwned,
53    {
54        serde_json::from_value(self.input.clone()).map_err(FlowError::from)
55    }
56}
57
58/// Step execution request passed to a runtime implementation.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct StepInvocation {
61    pub run_id: String,
62    pub step_id: String,
63    pub step_name: String,
64    pub input: JsonValue,
65    pub history: Vec<FlowEventEnvelope>,
66}
67
68impl StepInvocation {
69    /// Decode the step input into a host-defined serde type.
70    pub fn input_as<T>(&self) -> Result<T>
71    where
72        T: DeserializeOwned,
73    {
74        serde_json::from_value(self.input.clone()).map_err(FlowError::from)
75    }
76}
77
78/// Runtime boundary for workflow code and side-effecting steps.
79#[async_trait]
80pub trait FlowRuntime: Send + Sync {
81    /// Replay the deterministic workflow function and return the next command.
82    async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand>;
83
84    /// Execute one side-effecting step. The engine persists success/failure.
85    async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue>;
86}
87
88/// Configuration for the native TypeScript runtime adapter.
89#[derive(Debug, Clone)]
90pub struct NativeTsRuntimeConfig {
91    /// Compiler executable. Bare names use `PATH`; relative paths with a
92    /// directory component are resolved against the host process directory.
93    pub compiler_binary: PathBuf,
94    /// Artifact cache directory. Relative paths are resolved against the host
95    /// process directory before the compiler changes its working directory.
96    pub cache_dir: PathBuf,
97    /// Runtime working directory. Relative paths are resolved against the host
98    /// process directory, and workflow entrypoints are resolved from it.
99    pub working_dir: PathBuf,
100}
101
102impl NativeTsRuntimeConfig {
103    pub fn new(
104        compiler_binary: impl Into<PathBuf>,
105        cache_dir: impl Into<PathBuf>,
106        working_dir: impl Into<PathBuf>,
107    ) -> Self {
108        Self {
109            compiler_binary: compiler_binary.into(),
110            cache_dir: cache_dir.into(),
111            working_dir: working_dir.into(),
112        }
113    }
114}
115
116impl Default for NativeTsRuntimeConfig {
117    fn default() -> Self {
118        Self {
119            compiler_binary: PathBuf::from("a3s-flow-native-compiler"),
120            cache_dir: PathBuf::from(".a3s/flow/native-ts"),
121            working_dir: PathBuf::from("."),
122        }
123    }
124}
125
126/// Runtime that compiles TypeScript to a native executable and speaks JSON over
127/// stdin/stdout with that executable.
128#[derive(Debug, Clone)]
129pub struct NativeTsRuntime {
130    config: NativeTsRuntimeConfig,
131    max_stdout_bytes: usize,
132    max_stderr_bytes: usize,
133    compile_timeout: Option<Duration>,
134    invocation_timeout: Option<Duration>,
135    #[cfg(feature = "native-ts")]
136    compiler_identity_cache: CompilerIdentityCache,
137    #[cfg(feature = "native-ts")]
138    artifact_cache: ArtifactCache,
139}
140
141#[cfg(feature = "native-ts")]
142#[derive(Debug, Clone)]
143struct NativeArtifact {
144    compiler_binary: PathBuf,
145    working_dir: PathBuf,
146    entrypoint: PathBuf,
147    cache_entry: PathBuf,
148    cache_key: String,
149    binary: PathBuf,
150    source_hash: String,
151    source_snapshot: SourceSnapshot,
152}
153
154#[cfg(feature = "native-ts")]
155struct NativeProcessOutput {
156    status: std::process::ExitStatus,
157    stdout: Vec<u8>,
158    stderr: Vec<u8>,
159}
160
161#[cfg(feature = "native-ts")]
162#[derive(Debug)]
163enum NativeProcessOutputError {
164    Io(std::io::Error),
165    LimitExceeded { stream: &'static str, limit: usize },
166}
167
168/// Result of validating and compiling a native TypeScript workflow source.
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
170pub struct NativeTsRuntimePreflight {
171    /// Resolved workflow source entrypoint used by the compiler.
172    pub entrypoint: PathBuf,
173    /// Resolved native artifact path that will be invoked by the runtime.
174    pub artifact: PathBuf,
175    /// Stable hash of the workflow source and runtime identity fields.
176    pub source_hash: String,
177    /// True when the existing artifact cache entry was reused.
178    pub cache_hit: bool,
179}
180
181impl NativeTsRuntime {
182    /// Default maximum bytes retained from a compiler or runtime stdout pipe.
183    pub const DEFAULT_MAX_STDOUT_BYTES: usize = 8 * 1024 * 1024;
184
185    /// Default maximum bytes retained from a compiler or runtime stderr pipe.
186    pub const DEFAULT_MAX_STDERR_BYTES: usize = 256 * 1024;
187
188    pub fn new(config: NativeTsRuntimeConfig) -> Self {
189        Self {
190            config,
191            max_stdout_bytes: Self::DEFAULT_MAX_STDOUT_BYTES,
192            max_stderr_bytes: Self::DEFAULT_MAX_STDERR_BYTES,
193            compile_timeout: None,
194            invocation_timeout: None,
195            #[cfg(feature = "native-ts")]
196            compiler_identity_cache: CompilerIdentityCache::default(),
197            #[cfg(feature = "native-ts")]
198            artifact_cache: ArtifactCache::default(),
199        }
200    }
201
202    pub fn config(&self) -> &NativeTsRuntimeConfig {
203        &self.config
204    }
205
206    /// Override the independent byte limits for each compiler and runtime
207    /// stdout/stderr pipe.
208    ///
209    /// Exceeding either limit terminates the direct child process and returns a
210    /// runtime error. A zero limit allows an empty pipe but rejects its first
211    /// byte of output.
212    pub fn with_output_limits(mut self, max_stdout_bytes: usize, max_stderr_bytes: usize) -> Self {
213        self.max_stdout_bytes = max_stdout_bytes;
214        self.max_stderr_bytes = max_stderr_bytes;
215        self
216    }
217
218    /// Return the configured byte limit for each stdout pipe.
219    pub fn max_stdout_bytes(&self) -> usize {
220        self.max_stdout_bytes
221    }
222
223    /// Return the configured byte limit for each stderr pipe.
224    pub fn max_stderr_bytes(&self) -> usize {
225        self.max_stderr_bytes
226    }
227
228    /// Set the maximum duration of each cold compiler process.
229    ///
230    /// Cache hits do not start a compiler and therefore do not consume this
231    /// timeout. By default, compilation has no runtime-owned timeout and
232    /// remains bounded only by caller cancellation or an outer host timeout.
233    pub fn with_compile_timeout(mut self, timeout: Duration) -> Self {
234        self.compile_timeout = Some(timeout);
235        self
236    }
237
238    /// Set the maximum duration of each workflow or step artifact invocation.
239    ///
240    /// The timeout covers writing the complete request to stdin, reading both
241    /// output pipes, and waiting for process exit. By default, invocation has
242    /// no runtime-owned timeout and remains bounded only by caller cancellation
243    /// or an outer host timeout.
244    pub fn with_invocation_timeout(mut self, timeout: Duration) -> Self {
245        self.invocation_timeout = Some(timeout);
246        self
247    }
248
249    /// Return the configured cold-compilation timeout, if any.
250    pub fn compile_timeout(&self) -> Option<Duration> {
251        self.compile_timeout
252    }
253
254    /// Return the configured workflow and step invocation timeout, if any.
255    pub fn invocation_timeout(&self) -> Option<Duration> {
256        self.invocation_timeout
257    }
258
259    #[cfg(feature = "native-ts")]
260    pub async fn preflight(&self, spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
261        let (artifact, cache_hit) = self.compile_if_needed(spec).await?;
262        Ok(NativeTsRuntimePreflight {
263            entrypoint: artifact.entrypoint,
264            artifact: artifact.binary,
265            source_hash: artifact.source_hash,
266            cache_hit,
267        })
268    }
269
270    #[cfg(not(feature = "native-ts"))]
271    pub async fn preflight(&self, _spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
272        Err(FlowError::Runtime(
273            "native-ts feature is disabled for NativeTsRuntime".to_string(),
274        ))
275    }
276
277    #[cfg(feature = "native-ts")]
278    async fn artifact_for(&self, spec: &WorkflowSpec) -> Result<NativeArtifact> {
279        validate_native_ts_spec(spec)?;
280        let (compiler_binary, compiler_fingerprint) = self
281            .compiler_identity_cache
282            .resolve_and_fingerprint(&self.config.compiler_binary)
283            .await?;
284        let working_dir = absolute_from_current_dir(&self.config.working_dir)?;
285        let entrypoint = resolve_against(&working_dir, &spec.runtime.entrypoint);
286        let cache_dir = absolute_from_current_dir(&self.config.cache_dir)?;
287        let (source, source_snapshot) = SourceSnapshot::read(&entrypoint).await?;
288        // Keep the protocol-visible source hash portable, but scope the local
289        // native executable cache to every compile-environment input that can
290        // make identical workflow source produce an incompatible artifact.
291        let source_hash = native_source_hash(spec, &source);
292        let artifact_hash = native_artifact_cache_key(
293            &source_hash,
294            &compiler_binary,
295            &compiler_fingerprint,
296            &working_dir,
297            &entrypoint,
298            NATIVE_RUNTIME_PROTOCOL,
299        );
300        let name = format!("{}-{artifact_hash}", sanitize_filename(&spec.name));
301        let cache_entry = cache_dir.join(name);
302        Ok(NativeArtifact {
303            compiler_binary,
304            working_dir,
305            entrypoint,
306            binary: artifact_binary_path(&cache_entry),
307            cache_entry,
308            cache_key: artifact_hash,
309            source_hash,
310            source_snapshot,
311        })
312    }
313
314    #[cfg(feature = "native-ts")]
315    async fn compile_if_needed(&self, spec: &WorkflowSpec) -> Result<(NativeArtifact, bool)> {
316        let artifact = self.artifact_for(spec).await?;
317        match self
318            .artifact_cache
319            .inspect(&artifact.cache_entry, &artifact.cache_key)
320            .await?
321        {
322            ArtifactCacheState::Valid => return Ok((artifact, true)),
323            ArtifactCacheState::Missing => {}
324            ArtifactCacheState::Invalid(reason) => {
325                tracing::warn!(
326                    path = %artifact.cache_entry.display(),
327                    %reason,
328                    "repairing invalid native TypeScript cache entry"
329                );
330            }
331        }
332
333        let cache_dir = artifact.cache_entry.parent().ok_or_else(|| {
334            FlowError::Runtime(format!(
335                "native TypeScript cache entry {} has no parent directory",
336                artifact.cache_entry.display()
337            ))
338        })?;
339        tokio::fs::create_dir_all(cache_dir).await?;
340        // Bundle the executable and its integrity manifest in a unique cache
341        // entry. Renaming the directory is the atomic publish boundary, so a
342        // reader can observe either the complete entry or no entry at all.
343        let mut temporary_entry = TemporaryCacheEntryGuard::create(&artifact.cache_entry).await?;
344        let child = match Command::new(&artifact.compiler_binary)
345            .arg("compile")
346            .arg(&artifact.entrypoint)
347            .arg("-o")
348            .arg(temporary_entry.binary())
349            .current_dir(&artifact.working_dir)
350            .stdin(std::process::Stdio::null())
351            .stdout(std::process::Stdio::piped())
352            .stderr(std::process::Stdio::piped())
353            // A cancelled preflight must not leave the compiler running after
354            // its Rust future and temporary-artifact cleanup have disappeared.
355            .kill_on_drop(true)
356            .spawn()
357        {
358            Ok(child) => child,
359            Err(error) => {
360                temporary_entry.remove().await;
361                return Err(error.into());
362            }
363        };
364        let output = match communicate_with_bounded_output(
365            child,
366            "compiler",
367            None,
368            self.max_stdout_bytes,
369            self.max_stderr_bytes,
370            self.compile_timeout,
371        )
372        .await
373        {
374            Ok(output) => output,
375            Err(error) => {
376                temporary_entry.remove().await;
377                return Err(error);
378            }
379        };
380
381        if !output.status.success() {
382            temporary_entry.remove().await;
383            return Err(FlowError::Runtime(format!(
384                "native TypeScript compile failed: {}",
385                String::from_utf8_lossy(&output.stderr)
386            )));
387        }
388
389        match artifact
390            .source_snapshot
391            .still_matches(&artifact.entrypoint)
392            .await
393        {
394            Ok(true) => {}
395            Ok(false) => {
396                temporary_entry.remove().await;
397                return Err(FlowError::Runtime(format!(
398                    "native TypeScript source {} changed while it was being compiled; refusing to publish the artifact",
399                    artifact.entrypoint.display()
400                )));
401            }
402            Err(error) => {
403                temporary_entry.remove().await;
404                return Err(error);
405            }
406        }
407
408        if let Err(error) = self
409            .artifact_cache
410            .prepare(temporary_entry.path(), &artifact.cache_key)
411            .await
412        {
413            temporary_entry.remove().await;
414            return Err(error);
415        }
416        if let Err(error) = self
417            .artifact_cache
418            .publish(
419                temporary_entry.path(),
420                &artifact.cache_entry,
421                &artifact.cache_key,
422            )
423            .await
424        {
425            temporary_entry.remove().await;
426            return Err(error);
427        }
428        temporary_entry.disarm();
429
430        match self
431            .artifact_cache
432            .inspect(&artifact.cache_entry, &artifact.cache_key)
433            .await?
434        {
435            ArtifactCacheState::Valid => {}
436            ArtifactCacheState::Missing => {
437                return Err(FlowError::Runtime(format!(
438                    "native TypeScript cache entry {} disappeared after publication",
439                    artifact.cache_entry.display()
440                )));
441            }
442            ArtifactCacheState::Invalid(reason) => {
443                return Err(FlowError::Runtime(format!(
444                    "native TypeScript cache entry {} is invalid after publication: {reason}",
445                    artifact.cache_entry.display()
446                )));
447            }
448        }
449
450        Ok((artifact, false))
451    }
452
453    #[cfg(feature = "native-ts")]
454    async fn invoke<I, O>(
455        &self,
456        spec: &WorkflowSpec,
457        kind: NativeRuntimeKind,
458        payload: I,
459    ) -> Result<O>
460    where
461        I: Serialize + Send,
462        O: DeserializeOwned,
463    {
464        let (artifact, _) = self.compile_if_needed(spec).await?;
465        let request = serde_json::to_vec(&NativeRuntimeRequest::new(
466            kind,
467            spec.runtime.export_name.clone(),
468            artifact.source_hash,
469            payload,
470        ))?;
471
472        let child = Command::new(&artifact.binary)
473            .arg("--a3s-flow-runtime")
474            .stdin(std::process::Stdio::piped())
475            .stdout(std::process::Stdio::piped())
476            .stderr(std::process::Stdio::piped())
477            .current_dir(&artifact.working_dir)
478            // Boot timeouts, lease loss, shutdown, and caller cancellation all
479            // drop this future. Tie the direct artifact process to that owner.
480            .kill_on_drop(true)
481            .spawn()?;
482
483        let output = communicate_with_bounded_output(
484            child,
485            "runtime",
486            Some(request),
487            self.max_stdout_bytes,
488            self.max_stderr_bytes,
489            self.invocation_timeout,
490        )
491        .await?;
492        if !output.status.success() {
493            return Err(FlowError::Runtime(format!(
494                "native TypeScript runtime failed: {}",
495                String::from_utf8_lossy(&output.stderr)
496            )));
497        }
498
499        decode_native_response(kind, &output.stdout)
500    }
501}
502
503#[cfg(feature = "native-ts")]
504async fn communicate_with_bounded_output(
505    mut child: Child,
506    process_kind: &'static str,
507    stdin_bytes: Option<Vec<u8>>,
508    max_stdout_bytes: usize,
509    max_stderr_bytes: usize,
510    process_timeout: Option<Duration>,
511) -> Result<NativeProcessOutput> {
512    let stdin = match stdin_bytes {
513        Some(bytes) => Some((
514            child.stdin.take().ok_or_else(|| {
515                FlowError::Runtime(format!(
516                    "native TypeScript {process_kind} stdin pipe is unavailable"
517                ))
518            })?,
519            bytes,
520        )),
521        None => None,
522    };
523    let stdout = child.stdout.take().ok_or_else(|| {
524        FlowError::Runtime(format!(
525            "native TypeScript {process_kind} stdout pipe is unavailable"
526        ))
527    })?;
528    let stderr = child.stderr.take().ok_or_else(|| {
529        FlowError::Runtime(format!(
530            "native TypeScript {process_kind} stderr pipe is unavailable"
531        ))
532    })?;
533
534    let communication = collect_native_process_output(
535        &mut child,
536        stdin,
537        stdout,
538        stderr,
539        max_stdout_bytes,
540        max_stderr_bytes,
541    );
542    let output = match process_timeout {
543        Some(timeout) => match tokio::time::timeout(timeout, communication).await {
544            Ok(output) => output,
545            Err(_) => {
546                terminate_and_reap(&mut child).await;
547                return Err(FlowError::Runtime(format!(
548                    "native TypeScript {process_kind} timed out after {timeout:?}"
549                )));
550            }
551        },
552        None => communication.await,
553    };
554
555    match output {
556        Ok(output) => Ok(output),
557        Err(error) => {
558            // The read that crossed the limit stops consuming its pipe. Kill
559            // and reap the child so a blocked writer cannot outlive this call.
560            terminate_and_reap(&mut child).await;
561            match error {
562                NativeProcessOutputError::Io(error) => Err(error.into()),
563                NativeProcessOutputError::LimitExceeded { stream, limit } => {
564                    Err(FlowError::Runtime(format!(
565                        "native TypeScript {process_kind} {stream} exceeded the {limit}-byte limit"
566                    )))
567                }
568            }
569        }
570    }
571}
572
573#[cfg(feature = "native-ts")]
574async fn collect_native_process_output(
575    child: &mut Child,
576    stdin: Option<(ChildStdin, Vec<u8>)>,
577    stdout: ChildStdout,
578    stderr: ChildStderr,
579    max_stdout_bytes: usize,
580    max_stderr_bytes: usize,
581) -> std::result::Result<NativeProcessOutput, NativeProcessOutputError> {
582    let write_stdin = async move {
583        if let Some((mut stdin, bytes)) = stdin {
584            stdin
585                .write_all(&bytes)
586                .await
587                .map_err(NativeProcessOutputError::Io)?;
588            stdin
589                .shutdown()
590                .await
591                .map_err(NativeProcessOutputError::Io)?;
592        }
593        Ok(())
594    };
595    let wait = async { child.wait().await.map_err(NativeProcessOutputError::Io) };
596    let stdout = read_bounded_output(stdout, "stdout", max_stdout_bytes);
597    let stderr = read_bounded_output(stderr, "stderr", max_stderr_bytes);
598    let (status, (), stdout, stderr) = tokio::try_join!(wait, write_stdin, stdout, stderr)?;
599    Ok(NativeProcessOutput {
600        status,
601        stdout,
602        stderr,
603    })
604}
605
606#[cfg(feature = "native-ts")]
607async fn terminate_and_reap(child: &mut Child) {
608    let _ = child.start_kill();
609    let _ = child.wait().await;
610}
611
612#[cfg(feature = "native-ts")]
613async fn read_bounded_output<R>(
614    mut reader: R,
615    stream: &'static str,
616    limit: usize,
617) -> std::result::Result<Vec<u8>, NativeProcessOutputError>
618where
619    R: AsyncRead + Unpin,
620{
621    let mut output = Vec::with_capacity(limit.min(8 * 1024));
622    let mut buffer = [0_u8; 8 * 1024];
623    loop {
624        let count = reader
625            .read(&mut buffer)
626            .await
627            .map_err(NativeProcessOutputError::Io)?;
628        if count == 0 {
629            return Ok(output);
630        }
631        if count > limit.saturating_sub(output.len()) {
632            return Err(NativeProcessOutputError::LimitExceeded { stream, limit });
633        }
634        output.extend_from_slice(&buffer[..count]);
635    }
636}
637
638#[cfg(feature = "native-ts")]
639fn native_source_hash(spec: &WorkflowSpec, source: &[u8]) -> String {
640    stable_hash([
641        b"source".as_slice(),
642        spec.name.as_bytes(),
643        spec.version.as_bytes(),
644        spec.runtime.entrypoint.as_bytes(),
645        spec.runtime.export_name.as_bytes(),
646        source,
647    ])
648}
649
650#[cfg(feature = "native-ts")]
651fn native_artifact_cache_key(
652    source_hash: &str,
653    compiler_binary: &Path,
654    compiler_fingerprint: &str,
655    working_dir: &Path,
656    entrypoint: &Path,
657    protocol: &str,
658) -> String {
659    stable_hash([
660        b"a3s.flow.native_ts.artifact.v3".as_slice(),
661        source_hash.as_bytes(),
662        protocol.as_bytes(),
663        compiler_binary.as_os_str().as_encoded_bytes(),
664        compiler_fingerprint.as_bytes(),
665        working_dir.as_os_str().as_encoded_bytes(),
666        entrypoint.as_os_str().as_encoded_bytes(),
667        std::env::consts::OS.as_bytes(),
668        std::env::consts::ARCH.as_bytes(),
669    ])
670}
671
672#[cfg(feature = "native-ts")]
673fn validate_native_ts_spec(spec: &WorkflowSpec) -> Result<()> {
674    spec.validate()?;
675    if spec.runtime.kind != RuntimeKind::NativeTs {
676        return Err(FlowError::InvalidWorkflow(format!(
677            "NativeTsRuntime requires a native_ts workflow spec, got {:?}",
678            spec.runtime.kind
679        )));
680    }
681    Ok(())
682}
683
684#[async_trait]
685impl FlowRuntime for NativeTsRuntime {
686    #[cfg(feature = "native-ts")]
687    async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
688        let spec = invocation.spec.clone();
689        self.invoke(&spec, NativeRuntimeKind::Workflow, invocation)
690            .await
691    }
692
693    #[cfg(not(feature = "native-ts"))]
694    async fn run_workflow(&self, _invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
695        Err(FlowError::Runtime(
696            "native-ts feature is disabled for NativeTsRuntime".to_string(),
697        ))
698    }
699
700    #[cfg(feature = "native-ts")]
701    async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue> {
702        let spec = workflow_spec_from_history(&invocation.history)?;
703        self.invoke(&spec, NativeRuntimeKind::Step, invocation)
704            .await
705    }
706
707    #[cfg(not(feature = "native-ts"))]
708    async fn run_step(&self, _invocation: StepInvocation) -> Result<JsonValue> {
709        Err(FlowError::Runtime(
710            "native-ts feature is disabled for NativeTsRuntime".to_string(),
711        ))
712    }
713}
714
715#[cfg(feature = "native-ts")]
716fn workflow_spec_from_history(history: &[FlowEventEnvelope]) -> Result<WorkflowSpec> {
717    let first = history
718        .first()
719        .ok_or_else(|| FlowError::Runtime("step invocation has empty history".to_string()))?;
720    match &first.event {
721        crate::model::FlowEvent::RunCreated { spec, .. } => Ok(spec.clone()),
722        _ => Err(FlowError::Runtime(
723            "first history event is not run_created".to_string(),
724        )),
725    }
726}
727
728#[cfg(feature = "native-ts")]
729fn sanitize_filename(value: &str) -> String {
730    value
731        .chars()
732        .map(|ch| {
733            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
734                ch
735            } else {
736                '-'
737            }
738        })
739        .collect()
740}
741
742#[cfg(feature = "native-ts")]
743fn resolve_against(root: &Path, value: &str) -> PathBuf {
744    let path = PathBuf::from(value);
745    if path.is_absolute() {
746        path
747    } else {
748        root.join(path)
749    }
750}
751
752#[cfg(feature = "native-ts")]
753fn absolute_from_current_dir(path: &Path) -> Result<PathBuf> {
754    if path.is_absolute() {
755        return Ok(path.to_path_buf());
756    }
757    Ok(std::env::current_dir()?.join(path))
758}
759
760#[cfg(feature = "native-ts")]
761fn stable_hash(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> String {
762    let mut hasher = Sha256::new();
763    for part in parts {
764        let bytes = part.as_ref();
765        hasher.update(bytes.len().to_le_bytes());
766        hasher.update(bytes);
767    }
768    hex_lower(&hasher.finalize())
769}
770
771#[cfg(feature = "native-ts")]
772fn hex_lower(bytes: &[u8]) -> String {
773    const HEX: &[u8; 16] = b"0123456789abcdef";
774    let mut output = String::with_capacity(bytes.len() * 2);
775    for byte in bytes {
776        output.push(HEX[(byte >> 4) as usize] as char);
777        output.push(HEX[(byte & 0x0f) as usize] as char);
778    }
779    output
780}
781
782#[cfg(feature = "native-ts")]
783fn decode_native_response<O>(kind: NativeRuntimeKind, bytes: &[u8]) -> Result<O>
784where
785    O: DeserializeOwned,
786{
787    let response: NativeRuntimeResponse = serde_json::from_slice(bytes)?;
788    if response.protocol != NATIVE_RUNTIME_PROTOCOL {
789        return Err(FlowError::Runtime(format!(
790            "native TypeScript runtime protocol mismatch: expected {NATIVE_RUNTIME_PROTOCOL}, got {}",
791            response.protocol
792        )));
793    }
794    if response.kind != kind {
795        return Err(FlowError::Runtime(format!(
796            "native TypeScript runtime response kind mismatch: expected {}, got {}",
797            kind.as_str(),
798            response.kind.as_str()
799        )));
800    }
801    if !response.ok {
802        let error = response
803            .error
804            .unwrap_or_else(|| "runtime returned ok=false without an error".to_string());
805        return Err(FlowError::Runtime(error));
806    }
807    let output = response.output.ok_or_else(|| {
808        FlowError::Runtime("native TypeScript runtime returned ok=true without output".to_string())
809    })?;
810    serde_json::from_value(output).map_err(FlowError::from)
811}
812
813#[cfg(test)]
814mod tests {
815    #[cfg(feature = "native-ts")]
816    use super::{read_bounded_output, NativeProcessOutputError};
817    use super::{NativeTsRuntime, NativeTsRuntimeConfig};
818    use std::path::Path;
819    use std::time::Duration;
820
821    #[test]
822    fn native_ts_default_cache_stays_under_a3s_state_root() {
823        let config = NativeTsRuntimeConfig::default();
824
825        assert_eq!(config.cache_dir, Path::new(".a3s/flow/native-ts"));
826    }
827
828    #[test]
829    fn native_ts_runtime_output_limits_are_configurable() {
830        let runtime = NativeTsRuntime::new(NativeTsRuntimeConfig::default());
831
832        assert_eq!(
833            runtime.max_stdout_bytes(),
834            NativeTsRuntime::DEFAULT_MAX_STDOUT_BYTES
835        );
836        assert_eq!(
837            runtime.max_stderr_bytes(),
838            NativeTsRuntime::DEFAULT_MAX_STDERR_BYTES
839        );
840
841        let runtime = runtime.with_output_limits(123, 45);
842        assert_eq!(runtime.max_stdout_bytes(), 123);
843        assert_eq!(runtime.max_stderr_bytes(), 45);
844    }
845
846    #[test]
847    fn native_ts_runtime_timeouts_are_opt_in_and_configurable() {
848        let runtime = NativeTsRuntime::new(NativeTsRuntimeConfig::default());
849
850        assert_eq!(runtime.compile_timeout(), None);
851        assert_eq!(runtime.invocation_timeout(), None);
852
853        let runtime = runtime
854            .with_compile_timeout(Duration::from_secs(30))
855            .with_invocation_timeout(Duration::from_secs(5));
856        assert_eq!(runtime.compile_timeout(), Some(Duration::from_secs(30)));
857        assert_eq!(runtime.invocation_timeout(), Some(Duration::from_secs(5)));
858    }
859
860    #[cfg(feature = "native-ts")]
861    #[tokio::test]
862    async fn native_ts_output_reader_accepts_exact_limit_and_rejects_next_byte() {
863        let exact = read_bounded_output(&b"1234"[..], "stdout", 4)
864            .await
865            .unwrap();
866        assert_eq!(exact, b"1234");
867
868        let error = read_bounded_output(&b"12345"[..], "stdout", 4)
869            .await
870            .unwrap_err();
871        assert!(matches!(
872            error,
873            NativeProcessOutputError::LimitExceeded {
874                stream: "stdout",
875                limit: 4
876            }
877        ));
878    }
879}