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