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