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#[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 pub fn context(&self) -> WorkflowContext<'_> {
46 WorkflowContext::new(self)
47 }
48
49 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#[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 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#[async_trait]
80pub trait FlowRuntime: Send + Sync {
81 async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand>;
83
84 async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue>;
86}
87
88#[derive(Debug, Clone)]
90pub struct NativeTsRuntimeConfig {
91 pub compiler_binary: PathBuf,
94 pub cache_dir: PathBuf,
97 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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
214pub struct NativeTsRuntimePreflight {
215 pub entrypoint: PathBuf,
217 pub artifact: PathBuf,
219 pub source_hash: String,
221 pub cache_hit: bool,
223}
224
225impl NativeTsRuntime {
226 pub const DEFAULT_MAX_STDOUT_BYTES: usize = 8 * 1024 * 1024;
228
229 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 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 pub fn max_stdout_bytes(&self) -> usize {
262 self.max_stdout_bytes
263 }
264
265 pub fn max_stderr_bytes(&self) -> usize {
267 self.max_stderr_bytes
268 }
269
270 pub fn with_compile_timeout(mut self, timeout: Duration) -> Self {
276 self.compile_timeout = Some(timeout);
277 self
278 }
279
280 pub fn with_invocation_timeout(mut self, timeout: Duration) -> Self {
287 self.invocation_timeout = Some(timeout);
288 self
289 }
290
291 pub fn compile_timeout(&self) -> Option<Duration> {
293 self.compile_timeout
294 }
295
296 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 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 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 .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 .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 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}