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