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