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