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_hash, source_snapshot) = SourceSnapshot::read(&entrypoint, spec).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 artifact_hash = native_artifact_cache_key(
292            &source_hash,
293            &compiler_binary,
294            &compiler_fingerprint,
295            &working_dir,
296            &entrypoint,
297            NATIVE_RUNTIME_PROTOCOL,
298        );
299        let name = format!("{}-{artifact_hash}", sanitize_filename(&spec.name));
300        let cache_entry = cache_dir.join(name);
301        Ok(NativeArtifact {
302            compiler_binary,
303            working_dir,
304            entrypoint,
305            binary: artifact_binary_path(&cache_entry),
306            cache_entry,
307            cache_key: artifact_hash,
308            source_hash,
309            source_snapshot,
310        })
311    }
312
313    #[cfg(feature = "native-ts")]
314    async fn compile_if_needed(&self, spec: &WorkflowSpec) -> Result<(NativeArtifact, bool)> {
315        let artifact = self.artifact_for(spec).await?;
316        match self
317            .artifact_cache
318            .inspect(&artifact.cache_entry, &artifact.cache_key)
319            .await?
320        {
321            ArtifactCacheState::Valid => return Ok((artifact, true)),
322            ArtifactCacheState::Missing => {}
323            ArtifactCacheState::Invalid(reason) => {
324                tracing::warn!(
325                    path = %artifact.cache_entry.display(),
326                    %reason,
327                    "repairing invalid native TypeScript cache entry"
328                );
329            }
330        }
331
332        let cache_dir = artifact.cache_entry.parent().ok_or_else(|| {
333            FlowError::Runtime(format!(
334                "native TypeScript cache entry {} has no parent directory",
335                artifact.cache_entry.display()
336            ))
337        })?;
338        tokio::fs::create_dir_all(cache_dir).await?;
339        // Bundle the executable and its integrity manifest in a unique cache
340        // entry. Renaming the directory is the atomic publish boundary, so a
341        // reader can observe either the complete entry or no entry at all.
342        let mut temporary_entry = TemporaryCacheEntryGuard::create(&artifact.cache_entry).await?;
343        let child = match Command::new(&artifact.compiler_binary)
344            .arg("compile")
345            .arg(&artifact.entrypoint)
346            .arg("-o")
347            .arg(temporary_entry.binary())
348            .current_dir(&artifact.working_dir)
349            .stdin(std::process::Stdio::null())
350            .stdout(std::process::Stdio::piped())
351            .stderr(std::process::Stdio::piped())
352            // A cancelled preflight must not leave the compiler running after
353            // its Rust future and temporary-artifact cleanup have disappeared.
354            .kill_on_drop(true)
355            .spawn()
356        {
357            Ok(child) => child,
358            Err(error) => {
359                temporary_entry.remove().await;
360                return Err(error.into());
361            }
362        };
363        let output = match communicate_with_bounded_output(
364            child,
365            "compiler",
366            None,
367            self.max_stdout_bytes,
368            self.max_stderr_bytes,
369            self.compile_timeout,
370        )
371        .await
372        {
373            Ok(output) => output,
374            Err(error) => {
375                temporary_entry.remove().await;
376                return Err(error);
377            }
378        };
379
380        if !output.status.success() {
381            temporary_entry.remove().await;
382            return Err(FlowError::Runtime(format!(
383                "native TypeScript compile failed: {}",
384                String::from_utf8_lossy(&output.stderr)
385            )));
386        }
387
388        match artifact
389            .source_snapshot
390            .still_matches(&artifact.entrypoint, spec)
391            .await
392        {
393            Ok(true) => {}
394            Ok(false) => {
395                temporary_entry.remove().await;
396                return Err(FlowError::Runtime(format!(
397                    "native TypeScript source {} changed while it was being compiled; refusing to publish the artifact",
398                    artifact.entrypoint.display()
399                )));
400            }
401            Err(error) => {
402                temporary_entry.remove().await;
403                return Err(error);
404            }
405        }
406
407        if let Err(error) = self
408            .artifact_cache
409            .prepare(temporary_entry.path(), &artifact.cache_key)
410            .await
411        {
412            temporary_entry.remove().await;
413            return Err(error);
414        }
415        if let Err(error) = self
416            .artifact_cache
417            .publish(
418                temporary_entry.path(),
419                &artifact.cache_entry,
420                &artifact.cache_key,
421            )
422            .await
423        {
424            temporary_entry.remove().await;
425            return Err(error);
426        }
427        temporary_entry.disarm();
428
429        match self
430            .artifact_cache
431            .inspect(&artifact.cache_entry, &artifact.cache_key)
432            .await?
433        {
434            ArtifactCacheState::Valid => {}
435            ArtifactCacheState::Missing => {
436                return Err(FlowError::Runtime(format!(
437                    "native TypeScript cache entry {} disappeared after publication",
438                    artifact.cache_entry.display()
439                )));
440            }
441            ArtifactCacheState::Invalid(reason) => {
442                return Err(FlowError::Runtime(format!(
443                    "native TypeScript cache entry {} is invalid after publication: {reason}",
444                    artifact.cache_entry.display()
445                )));
446            }
447        }
448
449        Ok((artifact, false))
450    }
451
452    #[cfg(feature = "native-ts")]
453    async fn invoke<I, O>(
454        &self,
455        spec: &WorkflowSpec,
456        kind: NativeRuntimeKind,
457        payload: I,
458    ) -> Result<O>
459    where
460        I: Serialize + Send,
461        O: DeserializeOwned,
462    {
463        let (artifact, _) = self.compile_if_needed(spec).await?;
464        let request = serde_json::to_vec(&NativeRuntimeRequest::new(
465            kind,
466            spec.runtime.export_name.clone(),
467            artifact.source_hash,
468            payload,
469        ))?;
470
471        let child = Command::new(&artifact.binary)
472            .arg("--a3s-flow-runtime")
473            .stdin(std::process::Stdio::piped())
474            .stdout(std::process::Stdio::piped())
475            .stderr(std::process::Stdio::piped())
476            .current_dir(&artifact.working_dir)
477            // Boot timeouts, lease loss, shutdown, and caller cancellation all
478            // drop this future. Tie the direct artifact process to that owner.
479            .kill_on_drop(true)
480            .spawn()?;
481
482        let output = communicate_with_bounded_output(
483            child,
484            "runtime",
485            Some(request),
486            self.max_stdout_bytes,
487            self.max_stderr_bytes,
488            self.invocation_timeout,
489        )
490        .await?;
491        if !output.status.success() {
492            return Err(FlowError::Runtime(format!(
493                "native TypeScript runtime failed: {}",
494                String::from_utf8_lossy(&output.stderr)
495            )));
496        }
497
498        decode_native_response(kind, &output.stdout)
499    }
500}
501
502#[cfg(feature = "native-ts")]
503async fn communicate_with_bounded_output(
504    mut child: Child,
505    process_kind: &'static str,
506    stdin_bytes: Option<Vec<u8>>,
507    max_stdout_bytes: usize,
508    max_stderr_bytes: usize,
509    process_timeout: Option<Duration>,
510) -> Result<NativeProcessOutput> {
511    let stdin = match stdin_bytes {
512        Some(bytes) => Some((
513            child.stdin.take().ok_or_else(|| {
514                FlowError::Runtime(format!(
515                    "native TypeScript {process_kind} stdin pipe is unavailable"
516                ))
517            })?,
518            bytes,
519        )),
520        None => None,
521    };
522    let stdout = child.stdout.take().ok_or_else(|| {
523        FlowError::Runtime(format!(
524            "native TypeScript {process_kind} stdout pipe is unavailable"
525        ))
526    })?;
527    let stderr = child.stderr.take().ok_or_else(|| {
528        FlowError::Runtime(format!(
529            "native TypeScript {process_kind} stderr pipe is unavailable"
530        ))
531    })?;
532
533    let communication = collect_native_process_output(
534        &mut child,
535        stdin,
536        stdout,
537        stderr,
538        max_stdout_bytes,
539        max_stderr_bytes,
540    );
541    let output = match process_timeout {
542        Some(timeout) => match tokio::time::timeout(timeout, communication).await {
543            Ok(output) => output,
544            Err(_) => {
545                terminate_and_reap(&mut child).await;
546                return Err(FlowError::Runtime(format!(
547                    "native TypeScript {process_kind} timed out after {timeout:?}"
548                )));
549            }
550        },
551        None => communication.await,
552    };
553
554    match output {
555        Ok(output) => Ok(output),
556        Err(error) => {
557            // The read that crossed the limit stops consuming its pipe. Kill
558            // and reap the child so a blocked writer cannot outlive this call.
559            terminate_and_reap(&mut child).await;
560            match error {
561                NativeProcessOutputError::Io(error) => Err(error.into()),
562                NativeProcessOutputError::LimitExceeded { stream, limit } => {
563                    Err(FlowError::Runtime(format!(
564                        "native TypeScript {process_kind} {stream} exceeded the {limit}-byte limit"
565                    )))
566                }
567            }
568        }
569    }
570}
571
572#[cfg(feature = "native-ts")]
573async fn collect_native_process_output(
574    child: &mut Child,
575    stdin: Option<(ChildStdin, Vec<u8>)>,
576    stdout: ChildStdout,
577    stderr: ChildStderr,
578    max_stdout_bytes: usize,
579    max_stderr_bytes: usize,
580) -> std::result::Result<NativeProcessOutput, NativeProcessOutputError> {
581    let write_stdin = async move {
582        if let Some((mut stdin, bytes)) = stdin {
583            stdin
584                .write_all(&bytes)
585                .await
586                .map_err(NativeProcessOutputError::Io)?;
587            stdin
588                .shutdown()
589                .await
590                .map_err(NativeProcessOutputError::Io)?;
591        }
592        Ok(())
593    };
594    let wait = async { child.wait().await.map_err(NativeProcessOutputError::Io) };
595    let stdout = read_bounded_output(stdout, "stdout", max_stdout_bytes);
596    let stderr = read_bounded_output(stderr, "stderr", max_stderr_bytes);
597    let (status, (), stdout, stderr) = tokio::try_join!(wait, write_stdin, stdout, stderr)?;
598    Ok(NativeProcessOutput {
599        status,
600        stdout,
601        stderr,
602    })
603}
604
605#[cfg(feature = "native-ts")]
606async fn terminate_and_reap(child: &mut Child) {
607    let _ = child.start_kill();
608    let _ = child.wait().await;
609}
610
611#[cfg(feature = "native-ts")]
612async fn read_bounded_output<R>(
613    mut reader: R,
614    stream: &'static str,
615    limit: usize,
616) -> std::result::Result<Vec<u8>, NativeProcessOutputError>
617where
618    R: AsyncRead + Unpin,
619{
620    let mut output = Vec::with_capacity(limit.min(8 * 1024));
621    let mut buffer = [0_u8; 8 * 1024];
622    loop {
623        let count = reader
624            .read(&mut buffer)
625            .await
626            .map_err(NativeProcessOutputError::Io)?;
627        if count == 0 {
628            return Ok(output);
629        }
630        if count > limit.saturating_sub(output.len()) {
631            return Err(NativeProcessOutputError::LimitExceeded { stream, limit });
632        }
633        output.extend_from_slice(&buffer[..count]);
634    }
635}
636
637#[cfg(feature = "native-ts")]
638fn native_artifact_cache_key(
639    source_hash: &str,
640    compiler_binary: &Path,
641    compiler_fingerprint: &str,
642    working_dir: &Path,
643    entrypoint: &Path,
644    protocol: &str,
645) -> String {
646    stable_hash([
647        b"a3s.flow.native_ts.artifact.v3".as_slice(),
648        source_hash.as_bytes(),
649        protocol.as_bytes(),
650        compiler_binary.as_os_str().as_encoded_bytes(),
651        compiler_fingerprint.as_bytes(),
652        working_dir.as_os_str().as_encoded_bytes(),
653        entrypoint.as_os_str().as_encoded_bytes(),
654        std::env::consts::OS.as_bytes(),
655        std::env::consts::ARCH.as_bytes(),
656    ])
657}
658
659#[cfg(feature = "native-ts")]
660fn validate_native_ts_spec(spec: &WorkflowSpec) -> Result<()> {
661    spec.validate()?;
662    if spec.runtime.kind != RuntimeKind::NativeTs {
663        return Err(FlowError::InvalidWorkflow(format!(
664            "NativeTsRuntime requires a native_ts workflow spec, got {:?}",
665            spec.runtime.kind
666        )));
667    }
668    Ok(())
669}
670
671#[async_trait]
672impl FlowRuntime for NativeTsRuntime {
673    #[cfg(feature = "native-ts")]
674    async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
675        let spec = invocation.spec.clone();
676        self.invoke(&spec, NativeRuntimeKind::Workflow, invocation)
677            .await
678    }
679
680    #[cfg(not(feature = "native-ts"))]
681    async fn run_workflow(&self, _invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
682        Err(FlowError::Runtime(
683            "native-ts feature is disabled for NativeTsRuntime".to_string(),
684        ))
685    }
686
687    #[cfg(feature = "native-ts")]
688    async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue> {
689        let spec = workflow_spec_from_history(&invocation.history)?;
690        self.invoke(&spec, NativeRuntimeKind::Step, invocation)
691            .await
692    }
693
694    #[cfg(not(feature = "native-ts"))]
695    async fn run_step(&self, _invocation: StepInvocation) -> Result<JsonValue> {
696        Err(FlowError::Runtime(
697            "native-ts feature is disabled for NativeTsRuntime".to_string(),
698        ))
699    }
700}
701
702#[cfg(feature = "native-ts")]
703fn workflow_spec_from_history(history: &[FlowEventEnvelope]) -> Result<WorkflowSpec> {
704    let first = history
705        .first()
706        .ok_or_else(|| FlowError::Runtime("step invocation has empty history".to_string()))?;
707    match &first.event {
708        crate::model::FlowEvent::RunCreated { spec, .. } => Ok(spec.clone()),
709        _ => Err(FlowError::Runtime(
710            "first history event is not run_created".to_string(),
711        )),
712    }
713}
714
715#[cfg(feature = "native-ts")]
716fn sanitize_filename(value: &str) -> String {
717    value
718        .chars()
719        .map(|ch| {
720            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
721                ch
722            } else {
723                '-'
724            }
725        })
726        .collect()
727}
728
729#[cfg(feature = "native-ts")]
730fn resolve_against(root: &Path, value: &str) -> PathBuf {
731    let path = PathBuf::from(value);
732    if path.is_absolute() {
733        path
734    } else {
735        root.join(path)
736    }
737}
738
739#[cfg(feature = "native-ts")]
740fn absolute_from_current_dir(path: &Path) -> Result<PathBuf> {
741    if path.is_absolute() {
742        return Ok(path.to_path_buf());
743    }
744    Ok(std::env::current_dir()?.join(path))
745}
746
747#[cfg(feature = "native-ts")]
748fn stable_hash(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> String {
749    let mut hasher = Sha256::new();
750    for part in parts {
751        update_stable_hash_part(&mut hasher, part.as_ref());
752    }
753    hex_lower(&hasher.finalize())
754}
755
756#[cfg(feature = "native-ts")]
757fn update_stable_hash_part(hasher: &mut Sha256, bytes: &[u8]) {
758    hasher.update((bytes.len() as u64).to_le_bytes());
759    hasher.update(bytes);
760}
761
762#[cfg(feature = "native-ts")]
763fn hex_lower(bytes: &[u8]) -> String {
764    const HEX: &[u8; 16] = b"0123456789abcdef";
765    let mut output = String::with_capacity(bytes.len() * 2);
766    for byte in bytes {
767        output.push(HEX[(byte >> 4) as usize] as char);
768        output.push(HEX[(byte & 0x0f) as usize] as char);
769    }
770    output
771}
772
773#[cfg(feature = "native-ts")]
774fn decode_native_response<O>(kind: NativeRuntimeKind, bytes: &[u8]) -> Result<O>
775where
776    O: DeserializeOwned,
777{
778    let response: NativeRuntimeResponse = serde_json::from_slice(bytes)?;
779    if response.protocol != NATIVE_RUNTIME_PROTOCOL {
780        return Err(FlowError::Runtime(format!(
781            "native TypeScript runtime protocol mismatch: expected {NATIVE_RUNTIME_PROTOCOL}, got {}",
782            response.protocol
783        )));
784    }
785    if response.kind != kind {
786        return Err(FlowError::Runtime(format!(
787            "native TypeScript runtime response kind mismatch: expected {}, got {}",
788            kind.as_str(),
789            response.kind.as_str()
790        )));
791    }
792    if !response.ok {
793        let error = response
794            .error
795            .unwrap_or_else(|| "runtime returned ok=false without an error".to_string());
796        return Err(FlowError::Runtime(error));
797    }
798    let output = response.output.ok_or_else(|| {
799        FlowError::Runtime("native TypeScript runtime returned ok=true without output".to_string())
800    })?;
801    serde_json::from_value(output).map_err(FlowError::from)
802}
803
804#[cfg(test)]
805mod tests {
806    #[cfg(feature = "native-ts")]
807    use super::{read_bounded_output, NativeProcessOutputError};
808    use super::{NativeTsRuntime, NativeTsRuntimeConfig};
809    use std::path::Path;
810    use std::time::Duration;
811
812    #[test]
813    fn native_ts_default_cache_stays_under_a3s_state_root() {
814        let config = NativeTsRuntimeConfig::default();
815
816        assert_eq!(config.cache_dir, Path::new(".a3s/flow/native-ts"));
817    }
818
819    #[test]
820    fn native_ts_runtime_output_limits_are_configurable() {
821        let runtime = NativeTsRuntime::new(NativeTsRuntimeConfig::default());
822
823        assert_eq!(
824            runtime.max_stdout_bytes(),
825            NativeTsRuntime::DEFAULT_MAX_STDOUT_BYTES
826        );
827        assert_eq!(
828            runtime.max_stderr_bytes(),
829            NativeTsRuntime::DEFAULT_MAX_STDERR_BYTES
830        );
831
832        let runtime = runtime.with_output_limits(123, 45);
833        assert_eq!(runtime.max_stdout_bytes(), 123);
834        assert_eq!(runtime.max_stderr_bytes(), 45);
835    }
836
837    #[test]
838    fn native_ts_runtime_timeouts_are_opt_in_and_configurable() {
839        let runtime = NativeTsRuntime::new(NativeTsRuntimeConfig::default());
840
841        assert_eq!(runtime.compile_timeout(), None);
842        assert_eq!(runtime.invocation_timeout(), None);
843
844        let runtime = runtime
845            .with_compile_timeout(Duration::from_secs(30))
846            .with_invocation_timeout(Duration::from_secs(5));
847        assert_eq!(runtime.compile_timeout(), Some(Duration::from_secs(30)));
848        assert_eq!(runtime.invocation_timeout(), Some(Duration::from_secs(5)));
849    }
850
851    #[cfg(feature = "native-ts")]
852    #[tokio::test]
853    async fn native_ts_output_reader_accepts_exact_limit_and_rejects_next_byte() {
854        let exact = read_bounded_output(&b"1234"[..], "stdout", 4)
855            .await
856            .unwrap();
857        assert_eq!(exact, b"1234");
858
859        let error = read_bounded_output(&b"12345"[..], "stdout", 4)
860            .await
861            .unwrap_err();
862        assert!(matches!(
863            error,
864            NativeProcessOutputError::LimitExceeded {
865                stream: "stdout",
866                limit: 4
867            }
868        ));
869    }
870}