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