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